《Effective Modren C++》 进阶学习(上)

2023-11-15 19:09:39 浏览数 (1)

  • 引言
  • 1. 理解模板类型推导
  • 2. 理解auto类型推导
  • 3. 理解decltype
  • 4. 学会查看类型推导结果
  • 5. 优先考虑auto而非显式类型声明
  • 6. auto推导若非己愿,使用显式类型初始化惯用法
  • 7. 区别使用 () 和 {} 创建对象
  • 8. 优先考虑nullptr而非0和NULL
  • 9. 优先考虑别名声明而非typedef
  • 10. 优先考虑限域枚举而非未限域枚举
  • 11. 优先考虑使用deleted函数而非使用未定义的私有声明
  • 12. 使用override声明重写函数
  • 13. 优先考虑const_iterator而非iterator
  • 14. 如果函数不抛出异常请使用noexcept
  • 15. 尽可能的使用constexpr
  • 16. 让const成员函数线程安全
  • 17. 理解特殊成员函数的生成

引言


  作为一名有追求的程序猿,一定是希望自己写出的是最完美的、无可挑剔的代码。那完美的标准是什么,我想不同的设计师都会有自己的一套标准。而在实际编码中,如何将个人的标准愈发完善,愈发得到同事的认可,一定需要不断积累。如何积累,一定是从细微处着手,观摩优秀的代码,学习现有的框架,汲取前人留下的智慧。

  本篇是拜读《Effective Modren C 》后的笔记。《Effective Modren C 》是由世界顶级C 技术权威专家Scott Meyers所著, 旨在帮助开发者更好地理解和应用现代C 的特性和最佳实践。该书是Scott Meyers继《Effective C 》和《More Effective C 》之后的续集,针对C 11、C 14和C 17引入的新特性进行了深入讲解。

1. 理解模板类型推导


模板类型推导(template type deduction)指的是编译器通过函数参数的类型来推断模板参数的类型,从而确定函数模板的实例化类型。某些情况下,ParamType并不是和函数参数类型一样,而是依据参数推导出的(划重点)

使用模板:

代码语言:javascript复制
template<typename T>
void f(ParamType param); // ParamType 写法上包含T

f(expr);  // 从expr推导ParamType和T

一些情况下,ParamTypeexpr的类型相同;但是也存在两者不同的情况,此时T的推导也有所不同。分三种场景来分析:

「场景一:ParamType是指针或引用但不是通针引用」 在这种场景下,类型推导会如下进行:

  • 如果expr类型是引用,忽略引用部分。
  • 剩下的部分决定T,然后T与形参匹配得到ParamType。

举个例子,模板如下:

代码语言:javascript复制
template<typename T>
void f(T & param); //param是一个引用

声明如下变量:

代码语言:javascript复制
int x=27; //x是int
const int cx=x; //cx是const int
const int & rx=cx; //rx是指向const int的引用

当将如上变量传递给f时,推导如下:

代码语言:javascript复制
f(x);  //T是int,param的类型是int&
f(cx); //T是const int,param的类型是const int &
f(rx); //T是const int,param的类型是const int &

「场景二:ParamType是通用引用」 当ParamType是通用引用,情况会变得复杂。类型推导如下进行:

  • 如果expr是左值,TParamType都会被推导为左值引用。 第一,这是模板类型推导中唯一一种TParamType都被推导为引用的情况。 第二,虽然ParamType被声明为右值引用类型,但是最后推导的结果它是左值引用。
  • 如果expr是右值,就使用场景一的推导规则。

举个例子:

代码语言:javascript复制
template<typename T>
void f(T&& param);  //param现在是一个通用引用类型

int x=27;           //如之前一样
const int cx=x;     //如之前一样
const int & rx=cx;  //如之前一样

f(x);      //x是左值,所以T是int&
           //param类型也是int&

f(cx);     //cx是左值,所以T是const int &
           //param类型也是const int&

f(rx);     //rx是左值,所以T是const int &
           //param类型也是const int&

f(27);     //27是右值,所以T是int
           //param类型就是int&&

「场景三:ParamType既不是指针也不是引用」ParamType既不是指针也不是引用时,通过传值(pass-by-value)的方式处理:

代码语言:javascript复制
template<typename T>
void f(T param); //以传值的方式处理param

此时param会拷贝形参,因此对param的修改不会影响到原参数。类型推导如下进行:

  • 和之前一样,如果expr的类型是一个引用,忽略这个引用部分。
  • 如果忽略引用之后exprconst,那就再忽略const。如果它是volatile,也会被忽略(关于volatile的细节参考Item40)
代码语言:javascript复制
int x=27;          //如之前一样
const int cx=x;    //如之前一样
const int & rx=cx; //如之前一样
f(x);    //T和param都是int
f(cx);   //T和param都是int
f(rx);   //T和param都是int

当形参为指向const的指针或者指向const的引用时,在类型推导const会被保留。如下示例:

代码语言:javascript复制
template<typename T>
void f(T param); //传值

const char* const ptr = //ptr是一个常量指针,指向常量对象
" Fun with pointers";

此种情况,T会被推导为const char*,指针自身的const会被忽略,指向的数据为常量会被保留。

「数组实参」

  • 当数组作为实参,在场景三时,会被转化为指针形式推导。
  • 当数组作为实参,在场景一时,会被推到为数组的引用。
代码语言:javascript复制
const char array[] = "hello world"; 

template<typename T>
void f1(T param);   //传值

template<typename T>
void f2(T & param); //传引用

f1(array);  //被推导为const char *
f2(array);  //被推到为const char(&)[12]

「函数实参」 在函数作为实参时,也会被转化为指针推导。

代码语言:javascript复制
void someFunc(int, double); //someFunc是一个函数,类型是void(int,double)

template<typename T>
void f1(T param);   //传值

template<typename T>
void f2(T & param); //传引用

f1(someFunc); //param被推导为指向函数的指针,类型是void(*)(int, double)
f2(someFunc); //param被推导为指向函数的引用,类型为void(&)(int, bouel)

「小结」

  • 在模板类型推导时,有引用的实参会被视为无引用,他们的引用会被忽略
  • 对于通用引用的推导,左值实参会被特殊对待
  • 对于传值类型推导,实参如果具有常量性和易变性会被忽略
  • 在模板类型推导时,数组或者函数实参会退化为指针,除非它们被用于初始化引用

2. 理解auto类型推导


在大部分情况下auto推导与模板类型推导一致,仅当变量使用花括号初始化时,auto能够推导成std::initializer_list,而模板类型推导则无法推导。

代码语言:javascript复制
auto x1=27;   //类型是int,值是27
auto x2(27);  //同上
auto x3={27}; //类型是std::initializer_list<int>,值是{27}
auto x4{27};  //同上
auto x={11,23,9}; //x的类型是std::initializer_list<int>
auto x5={1,2,3.0}; //错误!存在不同类型,auto类型推导不能工作

「小结」

  • 当用auto声明的变量使用花括号进行初始化,auto推导的类型为std::initializer_list。这一点是模板类型无法做到的。

3. 理解decltype


decltype 是一种类型推导工具,用于获取表达式的类型而不执行该表达式。

通常被用于推导变量的类型和表达式的类型。

代码语言:javascript复制
int a = 1;
const int& x = a;
decltype(a) b = 2; // 推导出变量a的类型为int,b的类型也为int
decltype(x) c = b; // 推导出变量x的类型为const int&,c的类型也为const int&

int a = 1, b = 2;
decltype(a b) c = 3; // 推导出表达式a b的类型为int,c的类型也为int

「小结」

  • 如果表达式是一个变量名,则decltype推导出来的类型就是该变量的类型,而不是该变量的值的类型。
  • auto不同的是: auto在推导时会丢弃const和引用,decltype则可以保留类型的const和引用限定符,即推导出的类型与表达式的类型一致。

4. 学会查看类型推导结果


《Effective Modren C 》提供了三种查看类型推导的方式:

  • 编辑时,通过IDE编辑器 一些IDE编辑器支持显示程序代码中变量,函数,参数的类型。
  • 编译时,通过编译器诊断信息 通过编译器出错时提供的错误消息也可以查看推导结果。
  • 运行时,通过C 提供的接口typeid或者Boost.TypeIndex

但是编译器的打印的类型并不是完全可靠的!

5. 优先考虑auto而非显式类型声明


auto声明变量必须初始化,否则报错。(解决局部变量未初始化)

② 比起std::function, auto更省空间且快捷方便保存一个闭包的lambda表达式。

③ 对于STL容器遍历中,auto会避免异常隐蔽的错误。如《Effective Modren C 》举的例子:

代码语言:javascript复制
std::unordered_map<std::string,int> m;
...
for(const std::pair<std::string,int>& p : m)
{
...
}

std::unordered_map 的key是一个常量,所以std::pair的类型不是std::pair<std::string,int>而是 std::pair<const std::string,int>。为了对齐类型,编译器会创建一个临时对象,这个临时对象的类型是p想绑定到的对象的类型,即m中元素的类型,然后把p的引用绑定到这个临时对象上。在每个循环迭代结束时,临时对象将会销毁。如此一来,便会发生难以排查得bug。

使用auto可以避免这些很难被意识到的类型不匹配的错误:

代码语言:javascript复制
for(const auto & p : m)
{
...
}

「小结」 auto在使用时确实方便,但其也会降低代码可读性。因此在使用时可参考如下场景使用

  • 复杂类型名称较长: 当变量的类型名称非常冗长或复杂时,使用auto可以简化代码并提高可读性。例如,当类型名称包含模板或嵌套类型时,使用auto可以减少输入错误。
  • 类型明确可推导: 当变量的初始化表达式明确地指示了变量的类型时,使用auto可以简化代码,并减少重复输入类型名称的工作。这对于使用迭代器、范围基于循环或返回自动类型推导函数的函数等情况特别有用。
  • 类型变化频繁: 当代码中的类型可能经常改变时,使用auto可以使代码更加灵活和易于维护。如果变量的初始化表达式更改了类型,使用auto可以避免手动更改变量声明。

6. auto推导若非己愿,使用显式类型初始化惯用法


auto在推导时,可能返回的是引用类型,可能导致引用的对象被修改。因此在使用时,需要格外注意,可以通过显式初始化来规避此类问题。

代码语言:javascript复制
#include <iostream>
#include <vector>
using namespace std;
 
int main()
{
    std::vector<bool> array {false, false, false,false};

    bool value1 = array[1];  // value1 为bool
    auto value2 = array[2];  // value2 为std::vector<bool>::reference
    auto value3 = static_cast<bool>(array[3]); // value 为bool
 
    value1 = true;
    value2 = true;
    value3 = true;
 
    for (auto i: array) {
        std::cout << " " << i;
    }
    
    return 0;
}

输出

代码语言:javascript复制
 0 0 1 0

上述代码来看,修改value2的值时会直接修改到array[2],原因是value2auto推导的类型是std::vector<bool>::reference,即引用类型。而value3同样用auto,加上类型转换就无此问题(只是这样还不如直接用bool声明变量)。

7. 区别使用 () 和 {} 创建对象


「C 初始化方式」 C 的语法中,初始化的方式主要有三种方式:

代码语言:javascript复制
int x(0);      // 使用()初始化
int y = 0;     // 使用=初始化
int z{0};      // 使用{}初始化

另外也常用到一种,=和{}配合的初始化

代码语言:javascript复制
int z = {0};   // 使用=和{}

需要注意的是=在初始化时,并不是作为赋值运算符的,举一个自定义类的例子来说明:

代码语言:javascript复制
Widget w1;       //调用默认构造函数
Widget w2 = w1;  //不是赋值运算符,调用拷贝构造函数Widget(const &Widget),未定义时自动生成
w1 = w2;         //是一个赋值运算符,调用operator=函数
  • 括号初始化也可以用于为非静态数据成员指定默认初始值。C 11允许"="初始化也拥有这种能力:
代码语言:javascript复制
class widget {
...
private:
    int x = 0;   // 正确
    int y{0};    // 正确
    int z(0);    // 错误
};
  • 不可拷贝的对象,初始化时不可使用=赋值,但可以使用{}、()
代码语言:javascript复制
std::vector<int> ai1{0};  // 没问题,调用构造函数
std::atomic<int> ai2(0);  // 没问题,调用构造函数
std::atomic<int> ai3 = 0; // 错误!调用的拷贝函数

从上述看,在C 中这三种方式都被指派为初始化表达式,但是只有花括号任何地方都能被使用。因此花括号初始化又叫统一初始化。

「{}不允许变窄转换,()和=无此禁忌」 在使用{}初始化时,不允许内置类型隐式的变窄转换(narrowing conversion),()=不检查变窄转换。

代码语言:javascript复制
double x,y,z;
int sum1{x   y   z};  //错误!三个double的和不能用来初始化int类型的变量
int sum2(x   y   z);  // 没问题
int sum3 = x   y   z; // 没问题

「{}能避免C 最令人头疼的解析问题(most vexing parse)」 C 规定任何能被决议为一个声明的表达式必须被决议为声明,因此在使用()初始化变量时,一些情况会被编译器识别为函数声明。

作为对比,使用有参数的构造函数。

代码语言:javascript复制
Widget w1(10); // 没问题,使用实参10调用Widget的一个构造函数

需要初始化一个无参数的构造函数对象时,会变成函数声明。

代码语言:javascript复制
Widget w1(); // 有问题,会被识别为函数声明,期望是用无参构造函数构造对象

解决方法,可使用{}初始化,就无此问题。

代码语言:javascript复制
Widget w1{}; // 正确,调用无参构造函数构造对象

「{}使用时的缺点」

  • 上述描述了{}的种种优点,但其也存在一些缺点。原因在于第2节中描述,auto声明变量使用{}初始化时,会被推导为std::initializer_list
  • 另外,在构造函数有参数情况中,若不包含std::initializer_list参数或者 构造未传入实参,(){}产生一样的效果,否则{}优先匹配std::initializer_list参数的构造函数。
代码语言:javascript复制
class Widget {
public:
    Widget(int i, bool b); // 同上
    Widget(int i, double d); // 同上
    Widget(std::initializer_list<long double> il); 
…
};

Widget w1(10, true);  // 使用小括号初始化
                      // 调用第一个构造函数

Widget w2{10, true};  // 使用花括号初始化
                      // 调用第三个构造函数
                      // (10 和 true 转化为long double)
                      
Widget w3(10, 5.0);   // 使用小括号初始化
                      // 调用第二个构造函数
                      
Widget w4{10, 5.0};   // 使用花括号初始化
                      // 调用第三个构造函数
                      // (10 和 5.0 转化为long double)
  • 除此之外,在使用{}初始化时,参数能够被转换initializer_list,拷贝构造函数和移动构造函数都会被std::initializer_list构造函数优先匹配。
代码语言:javascript复制
class Widget {
    public:
    Widget(int i, bool b);
    Widget(int i, double d);
    Widget(std::initializer_list<long double> il);
    operator float() const; // convert to float 
};

Widget w5(w4); // 使用小括号,调用拷贝构造函数
Widget w6{w4}; // 使用花括号,调用std::initializer_list构造函数
Widget w7(std::move(w4)); // 使用小括号,调用移动构造函数
Widget w8{std::move(w4)}; // 使用花括号,调用std::initializer_list构造函数
  • 接着上述,在使用{}初始化时,只要参数能强转换为initializer_list<T>的T类型,就会只匹配std::initializer_list构造函数。因此,遇到变窄转换会编译报错。
代码语言:javascript复制
class Widget {
public:
    Widget(int i, bool b);
    Widget(int i, double d);
    Widget(std::initializer_list<bool> il); // element type is now bool
    … 
};

Widget w{10, 5.0}; // 编译器匹配initializer_list构造。编译错误!要求变窄转换

只有当传入的参数在编译器上无法转换成std::initializer_list<T>中的T类型,才会匹配普通的构造函数。

代码语言:javascript复制
class Widget {
public:
    Widget(int i, bool b);
    Widget(int i, double d);
    Widget(std::initializer_list<std::string> il);
…
};
  
Widget w1(10, true); // 使用小括号初始化,调用第一个构造函数
Widget w2{10, true}; // 使用花括号初始化,调用第一个构造函数
Widget w3(10, 5.0);  // 使用小括号初始化,调用第二个构造函数
Widget w4{10, 5.0};  // 使用花括号初始化,调用第二个构造函数
  • 最后在使用空参数{}初始化时,会匹配默认构造函数,只有传入{}才会匹配initializer_list构造函数。
代码语言:javascript复制
class Widget {
    public:
    Widget();
    Widget(std::initializer_list<int> il);
    ...
};

Widget w1;   // 调用默认构造函数
Widget w2{}; // 同上
Widget w3(); // 最令人头疼的解析!声明一个函数
Widget w3({}); // 匹配initializer_list构造函数
Widget w4{{}}; // 同上

「小结」 {}初始化看上去内容很庞大,综合上述内容,主要注意以下几点:

  • {}初始化能够在编译阶段杜绝变窄转换,另外也能避免C 最令人头疼的解析[1]。
  • 在构造重载匹配中,只要参数能够强转std::initializer_list<T>T,就会匹配std::initializer_list构造函数,即便有更加匹配的构造函数。
  • 对于数值类型的std::vector来说使用花括号初始化和小括号初始化会造成巨大的不同。
  • 当使用{}初始化无参数时,会优先匹配默认构造函数,如果要匹配std::initializer_list构造函数,需要传入{}

8. 优先考虑nullptr而非0和NULL


选择优先使用nullptr有如下原因:

  • 类型安全。0是整型,NULL类型不确定。两者未明确被指名是指针类型,在使用时可能会带来类型转换等问题。而nullptr为明确的空指针类型。
  • 避免重载解析歧义。传统的 0NULL 在函数重载中会引起歧义。而 nullptr 的类型是 std::nullptr_t,与整数类型有差异,可以显式地指定指针的空值,避免重载解析歧义。
  • nullptr看起来更舒服^_^。

9. 优先考虑别名声明而非typedef


优先选择使用别名(alias),主要原因在于别名可以被模版化,而typedef不行。

代码语言:javascript复制
// 别名实现模板
template<typename T>
using MyAllocList = std::list<T,MyAlloc<T>>;
MyAllocList<Widget> lw;
  
// typedef 实现模版 
template<typename T>
    struct MyAllocList {
    typedef std::list<T, MyAlloc<T>> type;
};
MyAllocList<Widget>::type lw;

10. 优先考虑限域枚举而非未限域枚举


首先了解未限域枚举和限域枚举:

代码语言:javascript复制
/// 未限域枚举 black, white, red 和 Color在相同作用域
enum Color 
{ 
    black, 
    white, 
    red 
};

// 限域枚举 black, white, red 限制在Color域内
enum class Color 
{ 
    black, 
    white,  
    red 
}; 

两者差异在于: 未限域枚举的枚举常量 (black、white) 与枚举类型(Color)在同一作用域;限域枚举的枚举常量(black、white)在枚举类型的作用域下。

限域枚举优点: ① 枚举名不会污染命名空间,即变量名与枚举名一致不会报错(限域枚举使用为Color::black,不会影响声明black变量)。当然遵循命名规范未限域枚举命名可以避免此问题。 ② 限域枚举的枚举名是强类型,未限域枚举中的枚举名会隐式转换为整型(现在,也可以转换为浮点类型)

11. 优先考虑使用deleted函数而非使用未定义的私有声明


在阻止类的某些特定成员函数被外部调用时,有两种常见的方法:使用 private 访问修饰符将其声明为私有,或者使用 delete 关键字将其声明为已删除。一般情况,优先考虑delete,原因如下:

  • delete明确表示该成员函数被删除或禁止使用。   C 11中实现一个空类,编译器会自动声明六个函数:默认构造函数、析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符。   由于编译器会自动生成上述函数,导致即使不定义,第三方仍然可以调用编译器自动生成的这些函数,这不是期望的动作!若使用private声明这些函数,还要实现其函数定义; 而delete只需要声明即可。
  • delete明确不可传入某些类型参数   例如参数为int类型,但实际传入bool参数也会强转调用,可以通过delete阻止。
代码语言:javascript复制
bool isLucky(int number);    // 原始版本
bool isLucky(char) = delete; // 拒绝char
bool isLucky(bool) = delete; // 拒绝bool

if (isLucky('a')) …  // 错误! 调用deleted函数
if (isLucky(true)) … // 错误!

「小结」

  • delete可以指定,当传入的类型不对时,编译报错。从而在编译期规避类型隐式转换带来的问题。

12. 使用override声明重写函数


C 中子类可以重写基类的虚函数,但两者必须完全相同,才会被编译器认定为是重写的函数; 否则会被认定为子类自身的函数成员,且编译器不会提醒。override可以解决此问题。

代码语言:javascript复制
class Base {
public:
    virtual int quiet()
    { }
};

class Derived : public Base {
public:
     // 重写父类接口quiet
     int quite() {}            // a.不符预期, 编译器不报错
     int quite() override { }  // b.不符预期, 编译器报错
};

如上,预期设计是子类重写基类的quiet接口,但实际上子类接口拼写错误。a在编译时不会提示错误,b在加上override后,明确声明此为重写接口,编译器在查询基类,编译报错无此接口。

「小结」

  • override可以明确此函数是重写的基类虚函数接口,当基类不存在此接口时就会编译报错。可以规避在声明子类接口时没有和基类保持一致,又难以察觉,导致子类接口在运行中没有被调用到这种低级问题。

13. 优先考虑const_iterator而非iterator


STL const_iterator等价于指向常量的指针。它们都指向不能被修改的值。标准实践是能加上const就加上,这也指示我们对待const_iterator应该如出一辙。

14. 如果函数不抛出异常请使用noexcept


noexcept是一个函数修饰符,用于指示函数不会抛出异常。使用noexcept修饰的函数被称为不抛异常的函数。

使用noexcept有以下几个原因:

  • 性能优化:当一个函数被标记为noexcept时,编译器可以进行一些优化,因为它知道函数不会抛出异常。这样可以提高程序的性能。
  • 接口约束:noexcept可以作为函数的接口约束,告诉调用者函数不会抛出异常。这样可以帮助调用者更好地处理异常情况,或者在编译时进行静态检查。
  • 异常安全性:在C 中,异常安全性是一个重要的概念,指的是程序在遇到异常时能够正确地处理资源的释放和状态的恢复。如果一个函数被标记为noexcept,那么调用该函数的代码就可以放心地假设函数不会抛出异常,从而更容易实现异常安全性。

使用noexcept修饰的函数必须确保不会抛出任何异常,否则程序将会终止。因此,在使用noexcept修饰函数时,需要仔细考虑函数的实现,确保不会出现意外的异常抛出。

15. 尽可能的使用constexpr


constexpr是用于声明常量表达式的关键字。常量表达式是在编译时求值的表达式,可用于变量函数和构造函数。

代码语言:javascript复制
constexpr int y = 10;
int arr[y]; // 合法:y是一个编译时常量

比起const,推荐使用constexpr的理由如下:

  • 编译时计算。使用constexpr声明的常量可以在编译时计算其值,而不需要在运行时计算。这意味着编译器可以优化代码,在编译阶段直接替换常量的值,从而减少运行时的计算开销。
  • 常量表达式。constexpr常量可以在编译时被用作常量表达式,例如作为数组大小、模板参数或其他需要常量表达式的上下文中使用。这样可以提高代码的灵活性和可读性。
  • 编译时错误检查。使用constexpr可以在编译时对常量表达式进行类型检查和错误检查。如果在常量表达式中使用了不允许的操作或无效的值,编译器会在编译时发出错误或警告,帮助我们及早发现并修复问题。

16. 让const成员函数线程安全


const成员函数意味着只读,因此这种函数在使用时会被默认为线程安全。但在实际编码中,实现的const成员函数可能存在线程不安全的情况。

代码语言:javascript复制
class Polynomial {
public:
    using RootsType = std::vector<double>;
    RootsType roots() const
    {
        if (!rootsAreVaild) { // 如果缓存不可⽤
            rootsAreVaild = true; // ⽤`rootVals`存储它们
        } 
        return rootVals;
    }

private:
    mutable bool rootsAreVaild{ false }; // initializers 的更多信息
    mutable RootsType rootVals{}; // 请查看条款7
};

上述代码会修改成员变量rootsAreVaild,假如多线程使用,会存在同时修改此成员,导致线程不安全。因此roots()接口虽然是const,但其依然线程不安全,规避的方法,可以用互斥量或者原子变量。

「总结」

  • 假如函数被声明为const,就应该被设计为线程安全的接口。其内部实现尽量不要有修改共享资源的操作(即尽量不要有修改公共变量的操作,否则用锁保护),且内部尽量少的调用其他的函数,因为被调用的函数也可能存在线程不安全的风险。

17. 理解特殊成员函数的生成


在C 术语中,特殊成员函数是指自己生成的函数。C 98有四个:默认构造函数、析构函数、拷贝构造函数和拷贝赋值函数。C 11又增加 两个特殊函数:移动构造函数和移动赋值函数。

代码语言:javascript复制
class Widget {
public:
...
  Widget();    // 默认构造函数
  ~Widget();   // 析构函数
  Widget(const Widget&);       // 拷贝函数
  Widget& operator=(Widget&);  // 拷贝赋值函数
  Widget(Widget&&);            // 移动构造函数 
  Widget& operator=(Widget&&); // 移动赋值函数
...
};

先了解一下C 11默认生成的成员函数,会有什么默认操作:

  • 「默认构造函数(Default Constructor)」 如果类没有任何构造函数,则编译器会自动生成默认构造函数。默认构造函数不执行任何操作,仅初始化成员变量。如果成员变量是内置类型,则执行默认初始化;如果成员变量是类类型,则调用相应的默认构造函数进行初始化。
  • 「析构函数(Destructor)」 自动生成的析构函数主要负责删除由对象所拥有的资源。对于类内部申请的资源,如动态分配的内存或打开的文件句柄等,编译器会在析构函数中自动释放这些资源。如果类没有显式声明析构函数,则会生成默认的析构函数,执行成员的析构操作。
  • 「拷贝构造函数(Copy Constructor)」 自动生成的拷贝构造函数执行的是浅拷贝,即逐个成员变量的进行拷贝。如果类中存在指针成员变量,则拷贝后的对象和原对象将共享相同的内存区域,这可能引发潜在的问题,需要注意。
  • 「拷贝赋值操作符(Copy Assignment Operator)」 自动生成的拷贝赋值操作符执行的是浅拷贝,即逐个成员变量的进行拷贝。与拷贝构造函数类似,可能存在共享资源的问题。
  • 「移动构造函数(Move Constructor)和移动赋值操作符(Move Assignment Operator)」 C 11引入了移动语义,使得在某些情况下可以使用移动操作来取代拷贝操作,提高效率。生成的移动构造函数和移动赋值操作符会对成员进行从一个对象到另一个对象的转移,而不是简单的进行值拷贝。

Rule of Three规则规定:如果类中声明了拷⻉构造函数,拷⻉赋值运算符,或者析构函数三者之⼀,就应该也声明其余两个。它来源于⻓期的观察,即⽤⼾接管拷⻉操作的需求⼏乎都是因为该类会做其他资源的管理,这也⼏乎意味着1)⽆论哪种资源管理如果能在⼀个拷⻉操作内完成,也应该在另⼀个拷⻉操作内完成2)类析构函数也需要参与资源的管理(通常是释放)

「总结」

  • 具体的原因可参考原文第17项,好的编程习惯应该显示的明确六个特殊成员的存在方式。需要使用默认的实现,则用default声明;不希望某个成员函数被调用,则使用delete声明;需要自定义实现,则自定义实现接口。

Reference

[1]

C 最令人头疼的解析,即most vexing parse: https://blog.csdn.net/janeqi1987/article/details/103684066

最后

用心感悟,认真记录,写好每一篇文章,分享每一框干货。

更多文章内容包括但不限于C/C 、Linux、开发常用神器等,可进入“开源519公众号”聊天界面输入“文章目录” 或者 菜单栏选择“文章目录”查看。公众号后台聊天框输入本文标题,在线查看源码。

0 人点赞