如何设计一个 C++ 的类
背景
如果要设计一个 C++的类,哪怕是最基础不过的类,也需要涉及到以下的知识点。能够熟悉并且驾驭这些知识点,是能够写出一个 C++类的前提。此外,这些知识点也如同一个 checklist,在 review 代码的时候可以对照着逐项进行检查:
- 对象的初始化方式;
- 构造函数;
- 拷贝构造函数、赋值运算符;
- 移动构造函数、移动赋值运算符;
- 析构函数;
- 继承体系;
- Rule of Three/Five;
- 访问控制;
- 传值和传引用;
- const 语义;
- 智能指针作为类的成员;
- 异常处理;
- 标准库使用;
- 模板设计模式;
- 线程安全及可重入;
上述内容中,“线程安全及可重入”、“模板设计模式”是非常大的话题,本文不涉及,请参考本专栏的其它文章;“异常处理”、“标准库使用”的内容比较多,本文只是捎带着提及,详细的讨论在本专栏的其它文章中。
基础类型在新标准中的初始化
开始本文前,先提及下内置类型在 C++11 标准后新增的初始化方式。
基础类型如下所示:
- bool
- char 系列:char、wchar_t、char16_t、char32_t;
- int 系列:short、int、long、long long;
- float 系列:float、double、long double;
孔乙己吃着茴香豆说,茴字有四样写法,你知道么? 在 c++里,基础类型也有四种初始化写法,你知道吗?
int gemfield = 7030; int gemfield(7030); int gemfield = {7030}; int gemfield{7030};
其中后两者就是 c++11 新增的 list initialization。基础类型如果没有像上面这样使用四种方式显式进行初始化的话,那么就会:
- 如果定义在函数外,那么会被默认初始化为 0;
- 如果定义在函数内,则默认初始化为 undefined 值;
类的构造函数
构造函数决定了一个类的对象是如何被初始化的。
1,默认构造函数与合成默认构造函数
没有参数的构造函数叫做默认构造函数(default constructor),如果一个类没有定义任何构造函数,那么编译器会自动生成一个,称之为合成默认构造函数(synthesized default constructor)。一旦类中用户显式的定义了任何的构造函数,那么编译器将停止自动生成默认构造函数。那么编译器生成的构造函数是如何知道怎么初始化当前类的各个成员呢?原则很简单:
- 如果成员有 in-class initializer,那么就用它初始化该成员;
- 反之,就 default-initialize 该成员。
有意思的事情来了。你来说说下面这个简单的代码中,类 Gemfield 初始化的时候,其成员 a_分别会被初始化几次呢?
class A{ public: A(int i){} }; //1st class Gemfield{ public: Gemfield(){} A a_={3}; }; //2nd class Gemfield{ public: Gemfield():a_(4){} A a_={3}; }; //3rd class Gemfield{ public: Gemfield():a_(4){a_= 5;} A a_={3}; };
请原谅我这里使用了带参数的构造函数来举例,主要是为了区分不同的初始化,原理一样。
- 第一种情况,成员 a_会使用 3 作为构造函数的参数初始化 1 次;
- 第二种情况,成员 a_会使用 4 作为构造函数的参数初始化 1 次;
- 第三种情况,成员 a_会使用 4 作为构造函数的参数初始化 1 次,然后在构造函数体内部使用参数 5 进行一个临时对象的构造(代码是等号右边的{5} ),然后会调用合成的 assign operator 将该临时对象赋值给 a_,这是 converting constructor 的一个典型场景。
因为默认构造函数在很多场景下都会被用到(比如:定义一个局部对象却没有使用 initializer 的时候;作为别的类的成员而该类又有默认构造函数的时候,等等),因此,设计一个类的最佳实践之一就是:总是为该 class 设计一个默认构造函数。
2,用户定义的构造函数
前面已经提到过,一旦类中用户定义了任何的构造函数,那么编译器将会停止自动生成默认构造函数。那么如果这种情况下,我们依然想要编译器自动生成一个默认构造函数,那该怎么办呢?使用新标准中的:
Gemfield() = default;
用户定义构造函数是更常见的行为,因为编译器合成的默认构造函数在大多数情况下都不能满足我们的需要,比如:
- 类中需要自定义的资源初始化方式;
- 类中某些成员并没有默认构造函数;
在用户定义的构造函数中,最重要的一个事情就是构造函数初始化列表(constructor initializer list)。如果编译器不支持 in-class initializer 的话,就只能使用 constructor initializer list 了。总而言之,在进入用户定义的构造函数体之前,类的成员已经被初始化过一遍了,使用的正是如下的三种方式之一(三选一):
- default initialize;
- in-class initializer;
- constructor initializer list;
而一旦进入构造函数体之后,再想修改该类的成员,就只能使用 assign operator 了。那么什么情况下只能使用构造函数初始化列表(constructor initializer list)来对类的成员进行初始化呢?以下四种情况:
- 成员是 const 类型;
- 成员是引用类型;
- 成员的类型没有定义默认构造函数;
- 追求构造效率的;
3,代理构造函数(delegating constructor)
C++11 还新增了delegating constructor,就是一个构造函数的构造函数初始化列表(constructor initializer list)部分可以换成其它参数列表的构造函数,然后再追加自身的函数体。
4,转换构造函数(Converting constructors)
当构造函数可以使用一个参数进行调用的时候,这个构造函数就是转换构造函数(Converting constructors)。构造函数可以使用一个参数进行调用意味着以下两种情况:
- 构造函数只有一个参数;
- 构造函数有多个参数,但除了第一个外都有默认值;
也就是说,转换构造函数(Converting constructors)定义了从构造函数的参数类型到当前 class type 的隐式转换。转换构造函数说的是一种类型通过构造函数转换为当前的 class 类型,那么这种行为的反义词——如何将当前 class 的类型转换为其它类型——是什么呢?Conversion operator !
转换构造函数(Converting constructors)有什么用呢?两种场景。
- 当赋值操作的时候,比如前面遇到过的代码:
class A{ public: A(int i){} }; class Gemfield{ public: Gemfield():a_(4){a_= 5;} A a_={3}; };
这里的 a_ = 5 能直接赋值,将 int 类型转化为了 class A 类型,就是因为 class A 的构造函数正是 converting constructor。
- 当传参的时候,比如函数的形参类型是 A,但是我们实参可以传递一个 int 类型。
注意,Converting constructors 只允许同时进行一种类型的转换,比如形参类型是 B,而 B 拥有一个 Converting constructors,其参数类型为 string,这个时候如果你传递字符串”this is gemfield test”是不行的,因为字符串到 string 是一次类型转换,而 string 到 class B 又是一个类型转换,同时有两次转换是不行的。解决方案就是你可以在这两处任选一处做个显式的强制类型转换。
标准库里有这样的用法吗?有。比如 string 类:
string gemfield = "this is gemfield test";
如果我想禁用转换构造函数(Converting constructors),那该怎么办呢?使用explicit关键字。explicit 用在声明的地方,不能用在定义的地方。值得注意的是,只有在转换构造函数(Converting constructors)上添加 explicit 关键字才有意义,如果构造函数有多个参数(也即不是 converting constructor),那本来也没有隐式的类型转换,那填不填加 explicit 就没有意义了。
一旦使用 explicit 关键字禁用了转换构造函数(Converting constructors),那么构造函数只能接收一样的类型来直接初始化了,或者使用显式的强制类型转换先将参数的类型转成一致的。
5,继承构造函数(inherit constructor)
C++11 后,我们可以在子类中使用 using 关键字来复用基类的构造函数:
class D : public B{ public: using B::B; };
称之为继承构造函数(inherit constructor)。通过使用 using 关键字,我们将基类 class B 中的所有的构造函数都以如下的形式在子类 class D 中重新定义了一遍:
D(params) : B(args) {};
这种继承构造函数有如下特点需要在实践中注意:
- 不管在哪写 using,子类构造函数的 access level 和基类一样(public、private、protected);
- 不能自行添加 explicit 和 constexpr,但是如果基类有 explicit 或 constexpr 那么子类也有,基类没有子类也没有;
- 基类构造函数中有默认参数的话,子类会去掉默认参数,并且生成多个版本的构造函数;比如基类只有一个构造函数(带两个参数,第二个参数有默认值),那么子类就会生成两个构造函数,其中一个有两个参数,另外一个只有一个参数;
- 如果基类中有多个构造函数,子类也会生成同样数量的构造函数(先不考虑上述的默认参数情况);但如果子类手动实现了其中一种,那只有其它的构造函数才会被继承过来;
- 自动合成的默认构造函数不会被继承,子类会由编译器使用同样的规则自动合成;
- 继承而来的构造函数并不会被当做用户自定义构造函数,因此,如果子类中只有这种继承而来的构造函数,那么编译器就会自动合成默认构造函数;
拷贝构造函数
如果构造函数的第一个参数是该类的引用类型,并且其它参数都有默认值,那么这个构造函数就是拷贝构造函数(copy constructor)。只要用户不自定义拷贝构造函数,编译器就会自动合成。合成拷贝构造函数的特点就是,它会执行对象之间的memberwise copy来进行对象的拷贝构造。拷贝构造函数在多个场景下都会用到:
- 拷贝初始化(Copy initialization),使用一个对象初始化另一个对象的时候,比如:
A a1 = a2;
注意,一定要和赋值运算符区分开来。等号左边是未初始化的对象时,这是拷贝构造;如果是已经初始化过(包括默认初始化),则是赋值运算符,比如下面这样就是赋值运算符:
A a1; //a1 已经默认初始化了 a1 = a2;
- 函数调用时候的传参(形参类型不是引用),也就是传值的时候会调用拷贝构造函数。这个很有趣,也解释了为什么拷贝构造函数的参数必须是引用类型。假设拷贝构造函数的参数类型不是引用而是传值,那么传值就要调用拷贝构造函数,这就变成了鸡生蛋和蛋生鸡的问题了。
- 接收返回值,且返回值类型不是引用的时候;
- 使用大括号初始化数组里的元素、aggregate class 的成员的时候;
注意,在标准库的容器添加元素的时候,insert、push 等是 copy initialization,而 emplace 操作是直接初始化。
赋值运算符
这个和拷贝构造函数类似,值得说的地方有 3 处(以编译器自动合成的赋值运算符为例):
- 为了和内置类型的行为一致,赋值运算符的返回值为等号左边操作符的引用;
- 参数不一定要为引用类型,因为没有鸡生蛋的问题;但最好是 reference to const 类型;
- 如果成员是数组类型,数组中的每个元素都会被赋值操作;
为什么叫赋值运算符而不是赋值构造函数呢?因为构造函数是没有返回值的,而赋值运算符是有返回值的。返回值的类型最好是 non-const reference,并且指向等号左边的对象。为什么呢?假设返回的是 void 或者其它类型,那么对于 a = b = c 这样的表达式怎么办呢?哈哈哈哈。
实践中一定要注意对参数进行检查,防止自己赋值给自己的情况发生。
移动构造函数和移动赋值运算符
移动语义是要作用在右值引用(Rvalue references)上的,你可以参考这篇文章: Gemfield:C++的类型推导zhuanlan.zhihu.com
在这篇文章中,你可以得知——比如吧,变量是“左值”,那么右值引用类型定义的变量也是“左值”,因此右值引用变量类型定义的变量(gemfield)是无法绑定到另一个右值引用类型变量(gemfield2)上的:
int&& gemfield = 7030; //错误 int&& gemfield2 = gemfield; //正确,你可以使用 std::move 将 gemfield 转换为右值 int&& gemfield2 = std::move(gemfield);
好了,回到本文。移动构造函数在 C++11 中出现的意义——或者说为了解决的问题——是对象在资源的管理中如何拥有更高的效率。比如,在如下的情况下,move 一个“资源”显然要比 copy 一个资源在效率上获得极大提升:
- a=b,当把 b 拷贝给 a 时,并且 b 又不再需要的时候。这个时候 move 就要好于 copy;把 b 的资源直接由 a 托管,比“a 中申请空间+b 拷贝给 a+销毁 b” 要快的多;
在如下的情况下,只有 move 一个“资源”才有意义:
- unique_ptr1 = unique_ptr2;因为 unique_ptr 只能独享资源,这个时候用 move 再合适不过了;
- IO 对象,因为 IO 对象的 buffer 无法共享;
这些“资源”就和我们前面提到的右值引用要产生关系了。根据上面的例子,我们得到一个重要的事实就是:右值引用(Rvalue references)引用的是那些即将销毁的对象,这也是为什么我们从右值引用上“移走”想要的资源是合理的。
标准库中的容器、string、shared_ptr 支持 copy 和 move,标准库中的 IO 和 unique_ptr 只有 move 语义(就像上面说的那样)。
一个类必须具备移动构造函数才具备移动语义,其对象才支持 move 操作,就像上面的容器、string 这样的类。那么如何为自己的类定义一个移动构造函数呢?我们可以换个视角,因为移动构造函数和拷贝构造函数最相似,我们就和拷贝构造函数来比较,看看这两者不一样的地方是什么:
- 移动构造函数的形参类型是右值引用类型,和拷贝构造函数的 reference to const 是不一样的:
Gemfield(Gemfield&&) = default;
- 移动构造函数的逻辑必须确保被 move 的对象处于一种无害状态,具体来说,被 move 的对象必须不再拥有其之前管理的资源,因为这些资源已经由新的对象接管了;被 move 的对象还需要能够被安全的析构;
- 移动构造函数不参与新的资源的申请,因此一般不会 throw 异常,于是最好是被 noexcept 修饰;此外,noexcept 修饰的移动构造函数还具备其它的意义,标准库能否看到这个 noexcept 承诺会有不一样的行为。比如 vector 在 push_back 一个对象时,如果该对象的 move constructor 不能承诺 noexcept,那么 vector 会转而使用这个对象的 copy constructor,以确保 vector 在 reallocation 错误的时候,不会犯下回不去的错误。
那么移动构造函数会像拷贝构造函数那样被编译器自动合成吗(如果用户没有自定义的话)?回顾一下,对于默认构造函数、拷贝构造函数、赋值运算符、析构函数来说,当用户没有声明且程序中使用到了该类型的构造、拷贝、赋值、析构,那么编译器会自动合成相应的默认构造函数、拷贝构造函数、赋值构造、析构函数;但是对于移动构造函数来说,情况就完全不一样了。
编译器只有在以下情况下才会自动合成移动构造函数:
- 用户没有声明拷贝构造函数,且
- 用户没有声明赋值运算符(copy assignment operator),且
- 用户没有声明移动赋值运算符(move assignment operator),且
- 用户没有声明析构函数,且
- 所有非 static 成员都是可 moveable 的,且
- 父类们都是可 moveable 的。
编译器只有在以下情况下才会自动合成移动赋值运算符:
- 用户没有声明拷贝构造函数,且
- 用户没有声明赋值运算符(copy assignment operator),且
- 用户没有声明移动构造函数(move constructor),且
- 用户没有声明析构函数,且
- 所有非 static 成员都是可 moveable 的,且
- 父类们都是可 moveable 的。
和拷贝构造不同的是,编译器不会自动合成=delete 的移动构造函数,但如果我们主动使用=default 来申请编译器来合成移动构造函数,而当前的类又因为下列情况而不满足 move 条件,那么合成的移动构造函数就是=delete 的:
- 成员的类型定义了 copy 但没定义 move;
- 成员类型的 move 是=delete 的或者不可访问(比如 private);
- 析构函数是=delete 的或者不可访问;
- 如果类有 const 或者 reference 类型,则合成移动赋值运算符会被=delete;
当然,编译器不自动合成那就用户自定义呗。
析构函数
和构造函数一样,析构函数也没有返回值;和构造函数又不一样,析构函数没有参数,因此没有重载——也就是说一个类中只有一个析构函数。如果用户不定义析构函数,编译器会合成一个析构函数,该析构函数的函数体为空。
构造函数的函数体是在成员都初始化完毕之后再执行的,与之相反,析构函数则是在函数体执行完毕再开始销毁类的成员的。
在日常的实践中,如果你设计的类有虚函数,或者你的类有可能被继承,那么就将析构函数加上 virtual 关键字,成为虚析构函数。为什么呢?如果析构函数不是 virtual 的,很可能在代码中会出现 base 指针指向子类的对象,这个时候如果 delete base 指针就会导致未定义的行为。
The Rule of Three/Five
理解了类中资源的管理,你就会得出如下结论:
- 如果该类显式的定义了析构函数,则一般也需要显式的定义拷贝构造函数、赋值运算符(除非使用=delete 直接禁止该类的拷贝和赋值);
- 如果该类显式的定义了拷贝构造函数,则也需要显式的定义赋值运算符;反之亦然;
- 我们应该把拷贝构造、赋值运算符、移动构造、移动赋值运算符、析构函数看成一个整体,如果用户定义了其中一个,就应该定义所有的;
这就是 C++11 之前的 Rule of Three,以及 C++11 之后的 Rule of Five。
再来讨论下自动合成
阅读完前面的文章,你也许看到或者在其它地方听说过,有些情况下“移动构造函数不会被合成,有些情况下“移动构造函数会被合成为=delete”,那这两者有什么区别呢?
- =delete 参与函数重载,如果被匹配到了,那就不好意思啦——编译报错;
- 不会被合成 表示没有被合成 / 根本不存在,那就不参与函数重载。
下面就列出一些场景,在这些场景下,编译器会自动合成相关的函数,并且被标记为=delete:
- 如果 class 中有一个成员的析构函数为=delete 或者不可访问(比如 private),那么该 class 的自动合成析构函数也会被标记为=delete;
- 如果 class 中有一个成员的拷贝构造函数为=delete 或者不可访问(比如 private),或者析构函数为=delete 或者不可访问(比如 private),那么该 class 的自动合成拷贝构造函数也会被标记为=delete;
- 如果 class 中有一个成员的赋值运算符为=delete 或者不可访问(比如 private),或者有成员的类型为 const 或者引用,那么该 class 的自动合成赋值运算符也会被标记为=delete;
- 如果 class 中有一个成员的类型为引用但是并没有 in-class 的 initializer,或者有一个成员的类型为 const 但是没有默认构造函数和 in-class initializer, 那么该 class 的自动合成默认构造函数也会被标记为=delete;
- 如果用户定义了移动构造函数或者移动赋值运算符,那么合成的拷贝构造函数和赋值运算符会被标记为=delete;
这里面其它规律还好说,但是=delete 的析构函数会导致合成默认构造函数、拷贝构造函数也为=delete 就让人猝不及防了。这是因为如果允许这种情况发生,那就会导致默认构造的对象无法析构。
如果感觉上面自动合成的场景有点复杂,那么在实践中就这么做,永远显示的定义这些函数。要么自定义,要们使用=default、=delete 等向编译器主动申请(C++11 之后)。
继承体系
OOP 的思想在于封装、继承、多态。前面对于资源的封装我们已经感受的差不多了,现在来简单说说继承吧。
继承体系下,在构造函数、拷贝构造、赋值运算符、移动构造函数中,子类需要显式的初始化基类部分;而在析构函数中,子类只需要关心子类的部分。
继承体系下,类的构造函数/析构函数中调用该类的虚函数,则虚函数的版本不是常规的多态中的行为,而是使用当前构造函数/析构函数所属的类中的虚函数版本。
继承在实践中有一些关键的点:
- 不想让别人继承的话,自己的类加上 final;
- 基类中的 virtual 函数,在子类中永远是 virtual 函数,不管在子类中加不加 virtual 关键字;
- override 关键字告诉用户:这是一个 virtual 函数, 而且是正在覆写基类中的虚函数;并且让编译器帮着做个检查,看是不是在覆写基类中的虚函数,而不是在创建一个新的函数(手抖什么的);
对于编译器自动合成的那些函数来说:
- 如果基类的默认构造函数、拷贝构造函数、赋值运算符、析构函数被标记为=delete 或者是不可访问的,那么子类中相应的成员也是=delete,这很好理解;
- 如果基类的析构函数是=delete 或者是不可访问的,那么子类中自动合成的默认构造函数、拷贝构造函数也会被标记为=delete;
- 如果基类的析构函数是=delete 或者是不可访问的,那么在子类中想用=default 来请求编译器自动合成移动构造函数的话,移动构造函数也会被标记为=delete;
- 如果基类中的移动构造函数是 deleted 或者是不可访问的,那么子类中想用=default 来请求编译器自动合成移动构造函数的话,移动构造函数也会被标记为=delete。
类的成员使用智能指针而不是裸指针
在普通函数里,我们都知道尽量要使用智能指针来管理动态内存。原因很简单,裸指针容易导致内存泄漏。让 Gemfield 来举个例子吧:
Gemfield* getGemfieldPtr(){ Gemfield* p_gem = new Gemfield(); return p_gem; }
在这个例子中,当函数返回后,是调用者的责任来确保 p_gem 指向的内存被释放掉。万一:
- 调用者忘记了呢?
- 当前的调用者没有忘记,后来新的 feature 添加后,新的代码忘记了呢?
- 调用者没有忘记,但代码因为异常等原因,函数提前返回从而没走到 delete 逻辑呢?
- 调用者的逻辑里有多个指针变量指向同样的内存,导致多次 delete 呢?
种种原因,导致我们在实践中一定要使用智能指针。这块的内容,请参考: Gemfield:C++的智能指针zhuanlan.zhihu.com
那么回到本篇文章,当写一个 C++的类时,如果类成员是指针类型,那么是用裸指针好还是智能指针好呢?试想下,如果使用裸指针的话,一般情况下,我们在构造函数中为其分配内存,在析构函数中释放其内存,听起来不错。但是再考虑以下的问题呢:
- 类中没有定义析构函数,使用的是自动合成的析构函数……;
- 构造函数中,刚刚为该成员 new 了内存,之后构造函数抛异常了……;
- 该类的对象之间互相拷贝、赋值时,如果是合成拷贝构造函数,裸指针……;如果是用户定义的拷贝构造函数、赋值运算符,裸指针……;
- 该类的对象使用移动语义时,裸指针……;
考虑到种种情况,我们在类成员中也要尽量避免使用裸指针,转而使用智能指针。那么是使用 shared_ptr 还是 unique_ptr 呢?
- 如果 class1 和 class2 之间需要共享成员的话,指针成员使用 shared_ptr;
- 如果 class1 和 class2 之间不共享成员的话,指针成员使用 unique_ptr;
- 如果是单例模式的话,指针成员使用 unique_ptr;
对于类的成员函数来说(好吧,也适用于非成员函数),使用智能指针的一些实践原则如下所示:
- 如果在函数中返回的资源想要由调用者管理的话,返回 unique_ptr,调用者之后可以自己再自由发挥,比如可以把它赋给 shared_ptr(如果想要的话);
- 从函数中返回 shared_ptr 相对比较少,如果出现这种情况,那意思就是:函数的设计者想要延长该函数中创建的资源的生命周期;
- 从函数中很少返回 weak_ptr,从调用者角度来看的话,如果调用者无从知晓返回的对象是否还在其生命周期中,则可以考虑使用 weak_ptr;
- 如果从函数中返回的资源的生命周期并不由调用者介入或者管理,并且也管理不了,那么函数返回裸指针或者引用。
异常处理和 stack unwinding
你设计的类的代码需要抛出异常吗?你需要 catch 自己代码(包括自己代码调用的三方库)的异常吗?只有清楚了解这些问题以及其后的背景,才能做到当异常被抛出的时候,程序依然能够行为正常,也就是常说的 exception safe 的代码。
比如,如果在类的构造函数中发生了异常呢?可能和很多人的直觉相反,在构造函数中因为错误而主动 throw 异常是标准行为。因为构造函数没有返回值,人们是无从得知其构造是否成功,如果有错误,就抛出异常。至于资源释放问题,请参考上述的智能指针环节。
class A{ public: A(int i){i_ = i;} ~A(){} private: int i_; }; class Gemfield{ public: Gemfield(){a2 = new A(2); throw std::exception();} ~Gemfield(){} private: A a1{1}; A* a2; };
上面的代码片段中,类 Gemfield 的构造函数中抛出了异常,请回答以下问题:
- Gemfield 的析构函数会被调用吗?
- a1 的析构函数会被调用吗?也就是 a1 会被释放吗?
- a2 的析构函数会被调用吗?a2 new 的资源会被释放吗?
如果在类的析构函数中发生了异常呢?应该在析构函数中抛出异常吗?这个小节的内容可以参考本专栏文章:C++的异常处理。
类设计中的其它小项
1,访问控制
就是 public、protected、private 这些关键字。注意,访问控制修饰符定义的是类的访问权限,而不是对象的访问权限。什么意思呢?比如 Gemfield 类有两个对象,g1 和 g2,g1 是可以访问 g2 的 private 成员的。
不然拷贝构造、赋值运算符是怎么访问参数对象里的私有成员的呀,哈哈哈哈。
2,函数传参和返回值
传值还是传引用?一切为了效率出发。下面列举几个经典场景:
- 参数需要在函数内修改,并在函数外使用修改后的值:传引用;
- 参数需要在函数内修改,但在函数外使用修改前的值:传值;
- 参数不会被修改,参数类型为基础类型的话传值,为 class 类型的话传引用,并且是 reference to const(参考: https://zhuanlan.zhihu.com/p/91075706 );
- 有些类型不允许 copy(比如标准 IO 类型),则只能传引用;
一个函数绝对不要返回其内部局部变量的引用或者指针(不过,有一个常见的用法是类成员函数返回对 this 对象的引用)。另外,在 C++11 及之后,可以返回一个 braced list 了。
3,const 成员函数
是否需要使用 const 关键字来修饰成员函数呢?const 成员函数表明当前对象的“只读”性,一旦成员函数使用了 const 来修饰,就表明成员函数上隐式的 this 指针是 reference to const 的(注意:this 本来就是 const 的),因此:
- const 对象上只能调用 const 成员函数;
- 非 const 对象上既可以调用非 const 成员函数,也可以调用 const 成员函数。
否则报错:error: ‘this’ argument to member function ‘xxxx’ has type ‘const X’, but function is not marked const。
4,使用 C++ 标准库
C++标准库不用白不用,关键是,在任何一个支持 C++的环境上,都默认有标准 C++库(其它的库就没有这个地位了),所以我们应该优先使用标准库;标准库不满足的,优先使用 header-only 的库。C++标准库主要有:
- 顺序容器;vector、deque、list、forward_list、array、string;
- 关联容器;map、set、multimap、multiset、unordered_map、unordered_set、unordered_multimap、unordered_multiset;
- 通用算法;160 多个通用算法、容器相关的算法。
关于 C++标准库,请参考本专栏的其它文章:C++的标准库。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

上一篇: ECAPA-TDNN 声纹模型
下一篇: 谈谈自己对于 AOP 的了解
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论