C++11新特性总结

本人也还刚刚入门C++,如有错误,望指出,谢谢!

move semantics (移动语义)

参考资料 :

这个网站的大部分资料对初学者都极其友好

stanford CS106L move semantics

https://www.youtube.com/watch?v=St0MNEU5b0o&ab_channel=CppCon

《C++ Move Semantics The Complete Guide》, 对C++移动语义相关概念的讲解很细致全面

《effective modern c++》,此书三分之一的篇幅都与右值和移动语义有关,如果这本书看起来有些吃力可以先看看上一本书

为什么需要移动语义

假设我们先定义并初始化vector v1和v2,v1中有5个int,而v2为空, 然后执行 v2 = v1, 这会调用拷贝构造函数,会将v1的所有元素拷贝至v2。

image-20220910202351116

一些情况下,这样的深拷贝是必要,但是有时候确实是低效的。就比如我们有createVector这样的函数,它会返回一个vector对象。在c++11之前,这样的代码

std::vector<int> v2{};
v2 = createVector();

将会对createVector返回的临时对象进行拷贝,即会在堆上分配新的空间将临时对象的内容拷贝过来,进而重置v2的状态。但是我们知道这个临时对象是很快就会被析构的(它将在下一行被析构),我们完全可以使v2“窃取”这个临时对象在堆上的内容。

就像下面这样,vector对象总共就存储了3个指针来管理整个数组,只要将指针拷贝过来再把temp对象的指针置为0就可以了。

image-20220910202720578

什么样的对象可以窃取呢?---那些生命值非常短暂的对象,那些临时对象。这些对象可以绑定到右值引用上(这是C++11为了支持移动语义,新提出的一种引用类型),一旦察觉到一个引用是一个右值引用,那么编译器就可以直接窃取它们的所有物而不是拷贝它们(通常的表现是:编译器倾向于选择执行移动构造\赋值,而不是选择拷贝构造\赋值)。

右值引用与左值引用的最大区别在于 : 右值引用的生命周期更短暂, 通常右值引用的作用域只在一行之内。

image-20220910201706713

左值引用可以用取地址符号 & 进行操作, 但是右值引用不可以

由于右值引用的生命周期非常短,所以也就意味着我们可以“窃取”右值引用的所有物。

如何窃取这些暂态对象?我们可以定义移动构造\赋值函数。

移动构造\赋值函数

移动构造函数与移动语义一同被提出,C++11以后很多的stl容器添加了对应的移动构造\赋值函数。比如vector容器的operator=,在C++11后有两种典型的重载:

vector& operator=( const vector& other ); // 经典的拷贝赋值函数,执行深拷贝过程
vector& operator=( vector&& other ); // c++11起,移动赋值函数,执行“浅拷贝”过程

第一种则是经典的拷贝赋值函数;而第二种则是移动赋值函数。C++11后,如果我们再写这样的代码:

std::vector<int> v2{};
v2 = createVector();

编译器将识别到 = 右边是一个临时对象,将调用移动赋值函数将临时对象的元素“窃取”至v2中,提高了执行效率。

类的移动构造函数何时自动生成

如果程序员不声明(也不能标记为 =default 或者 =delete)5个特殊成员函数(拷贝构造、拷贝赋值、移动构造、移动赋值、析构函数)的任何一个,且类的每个非静态成员都是可移动时,那么编译器会为这个class自动生成移动构造和移动赋值。反之,如果手动定义了,或者只是将拷贝构造函数标记为 =default,那么编译器就不会为这个class生成移动构造和赋值函数。

如何编写自己的移动构造函数?

编写示范如下,其中与std::move相关的讨论见下一小节:

image-20220910204507151

因为int是基本类型,所以在初始化阶段无论你用不用std::move转换都不会出错。但是对于字符串s来说就不一样了,如果我们不加std::move就会出错,因为 即使移动构造函数接受右值引用,但是 w 在这个构造函数中是一个左值引用(因为它有名字w),所以 w.s 也是一个左值引用,我们要调用 std::move(w.s)将字符串转换为左值,否则我们将会复制字符串而不是移动。右值引用的这个特性会在之后的内容中,引出“完美转发”这个话题

另外,还需要将w.pi置为nullptr,为什么?因为右值引用所绑定的对象是即将消亡的,当它在被析构时,只有将它所管理的指针置零,才不会将已经被转移的数据删除,才不会造成未定义行为。

std::move

直接从代码例子看move的作用,图源

image-20220910203723157

第一个赋值动作 v2 = v1 ,会调用vector的拷贝赋值函数,因为v1是一个左值;

第二个赋值动作中编译器识别到 = 号右边是一个临时对象,所以调用移动赋值操作符,这正好满足我们的需求。

而第三个赋值操作,std::move,将v1这个左值引用转换为了右值引用(std::move仅仅是一个static_cast),所以第三个赋值动作也会调用移动赋值函数。

请注意我们调用了 std::move(v1),它仅仅是对这个变量贴了一个标签,告诉编译器我们之后不会用到v1了,所以实际上std::move不会“移动”任何东西,它只是改变了变量的类型(从左值到右值),使得编译器选择了移动赋值函数。真正能够体现“move”的,是类的移动构造\赋值函数。

如果使用了move作用后的变量会怎么样?

不确定,我们不能对被move作用后的变量做出假设,C++标准只是规定这些被移动的对象(Moved From Object)处在一个未知但有效的状态(valid but unspecified state),这取决于函数编写者的具体实现。

但同时C++标准也规定这些处于未知但有效状态的被移动的对象能够:

  • 被摧毁,即能够调用析构函数
  • 被重新赋值
  • 赋值、拷贝、移动给另一个对象

因此一个被移动的对象,我们尽可能不要去操作它的指针类型成员,很可能造成未定义行为,但如果我们重新为这个被移动对象赋予了新的、有效的值,那么我们就可以重新使用它。

std::vector<int> v1{createVector();};
std::vector<int> v2{std::move(v1)};
// v1在被重新赋值之前,它处于未知状态,最好不要去使用它
v1 = createVector();
doSomething(v1); // v1被重新赋值后,我们又可以正常使用它了

noexcept与移动语义

下面是《C++ Move Semantics The Complete Guide》一书中的例子:

class Person{
private:
    std::string name;

public:
    Person(const char* c_name):name{c_name} {}
    // 拷贝构造
    Person(const Person& other):name{other.name} {
        std::cout << name << " COPY constructed!" << std::endl;
    }    
    // 移动构造
    Person(Person&& other):name{std::move(other.name)} {
        std::cout << name << " MOVE constructed!" << std::endl;
    }
};

为Person类定义了拷贝构造函数和移动构造函数,并在函数体中打印提示动作。

然后,观察Person类对象与vector相关的动作:(注意下面的例子的字符串都很长,这是为了抑制小型字符串优化(SSO,具体实现依赖于union的特性,共用capacity字段和小型字符串的存储空间)),即短小的字符串类将直接在栈上保存内容,而非在堆开辟空间,栈上存放指向堆空间的指针;如果发生了SSO优化,那么移动操作并不比复制操作更快)

int main() {
    Person p1{"Wolfgang Amadeus Mozart"};
    Person p2{"Johann Sebastian Bach"};
    Person p3{"Ludwig van Beethoven"};
    
    std::cout << "\n push 3 ele in a vector whose capacity is 3 : \n";
    std::vector<Person> v1;
    v1.reserve(3);
    v1.push_back((std::move(p1)));
    v1.push_back((std::move(p2)));
    v1.push_back((std::move(p3)));

    std::cout << "\n push 4th ele in the vector, which will cause reallocation : \n";
    Person p4{"Poelea Selo Beajuhhdda"};
    v1.push_back(std::move(p4));
}

输出如下:

push 3 ele in a vector whose capacity is 3
Wolfgang Amadeus Mozart MOVE constructed!
Johann Sebastian Bach MOVE constructed!
Ludwig van Beethoven MOVE constructed!

push 4th ele in the vector, which will cause reallocation
Poelea Selo Beajuhhdda MOVE constructed!
Wolfgang Amadeus Mozart COPY constructed!
Johann Sebastian Bach COPY constructed!
Ludwig van Beethoven COPY constructed!

可以看到,在vector进行reallocation之前的所有push_back都使用了右值引用的版本,因为我们对具名对象使用了std::move使其转换成了右值。

但是当vector发生reallocation后,元素却是被拷贝到新的空间中的,照理说应该使用移动更方便才对,为什么编译器在这里使用了拷贝语义?

原因可能出在vector的push_back是“强异常安全保证”的函数:如果在vector的reallocation期间有异常抛出,C++标准库得保证将vector回滚到它之前的状态。

为了实现这种事务特性,比较容易的做法就是在重分配的过程中使用拷贝,如果有任何一个元素分配空间失败或者拷贝失败,那么仅仅把新创建的元素销毁然后释放空间就可以回滚到先前的状态了。

相对的,使用移动来实现这种事务特性就比较困难了,试想在reallocation期间有异常抛出,此时新的空间的元素已经“窃取”了就空间的元素,因此想要回退到先前的状态,销毁新元素是不够的,我们还得将新元素移回旧空间中--问题来了,怎么保证这个移动操作不发生任何错误呢?

可以看到,使用移动语义难以保证这种事务特性,除非编译器知道这个类的移动构造函数不会抛出任何异常,否则它会在vector的reallocation期间选择拷贝元素,而不是移动元素

而noexcept关键字就能够告知编译器:该方法不会抛出异常,如果我们在Person的移动构造函数后加上noexcept关键字,编译器就会在vector的reallocation期间选择移动构造函数。

Person(Person&& other) noexcept :name{std::move(other.name)} {
    std::cout << name << " MOVE constructed!" << std::endl;
}

实际上,编译器自动生成的移动构造函数会检测:

  • 基类的移动构造是否noexcept
  • 类成员的移动构造是否noexcept

如果满足,则编译器自动生成的移动构造函数会自动加上noexcept关键字

Person(Person&& other) = default; // 使用编译器生成的移动构造函数

输出如下:

push 3 ele in a vector whose capacity is 3 : 

push 4th ele in the vector, which will cause reallocation :

没有拷贝构造函数的输出提示,表明重分配阶段使用了移动构造函数,也说明编译器为它自己生成的移动构造函数后加上了noexcept。

std::move 使用实例

来自CMU15445lab源码

// executor_factory.cpp    
// Create a new insert executor
    case PlanType::Insert: {
      auto insert_plan = dynamic_cast<const InsertPlanNode *>(plan);
      auto child_executor =
          insert_plan->IsRawInsert() ? nullptr : ExecutorFactory::CreateExecutor(exec_ctx, insert_plan->GetChildPlan());
      return std::make_unique<InsertExecutor>(exec_ctx, insert_plan, std::move(child_executor)); // move了child_executor
    }

InsertExecutor的构造函数应该这样写:

InsertExecutor::InsertExecutor(ExecutorContext *exec_ctx, const InsertPlanNode *plan,
                               std::unique_ptr<AbstractExecutor> &&child_executor)
    : AbstractExecutor(exec_ctx), plan_(plan), child_executor_(std::move(child_executor)) {

如果把初始化列表中的std::move去掉,编译器报错如下:

Call to deleted constructor of 'std::unique_ptr<AbstractExecutor>', uniqueptr的拷贝构造函数是被删除的,所以我们不能用左值引用初始化一个uniqueptr,所以我们必须调用std::move将child_executor变量先转换为右值引用,这也说明了child_executor即使被绑定到一个右值引用上,它本身却是一个左值引用。

但是我们调用构造函数的时候确实将左值转换成右值了不是吗?

std::make_unique<InsertExecutor>(exec_ctx, insert_plan, std::move(child_executor));

可以这样理解,在这一行的作用域中, std::move(child_executor) 确实将左值转换成了右值,编译器确定child_executor在这一行以后将不会再被使用。但是进入到拷贝函数的作用域中,编译器又不能确定该参数的生命周期了,因此在拷贝函数的作用域中还是将其看作左值类型。

一句话总结就是,右值变量在连续的嵌套作用域中并不会传递"右值"这个属性,因此我们有了下一章对“完美转发”的讨论

完美转发

在《C++ Move Semantics The Complete Guide》一书中,它将完美转发放在了第三部分Move Semantics in Generic Code,也就是说完美转发是同时涉及到移动语义和泛型编程的一个概念。

参考资料:

为什么需要完美转发

“转发”的含义是一个函数把自己的形参传递给另一个函数(即调用另一个函数),但是在引入右值后,这些转发可能需要花费一些精力:

比如现有3个版本的foo()函数:

class X{
public:
    X() {a = 1;}
    int a ;
};
void foo(const X& x) {// 绑定所有只读变量
    // do some read only job
    cout << "foo(const X& x) called\n";
} 

void foo(X& x) { // 绑定左值引用
    // do a lot of job, can modify x 
    cout << "foo(X& x) called\n";
} 

void foo(X&& x) { // 绑定右值引用
    // do a lot of job, can modify x, even can move x since x is rvalue references
    cout << "foo(X&& x) called\n";
    // std::move(x) is valid!
} 

假如要通过另一个函数callFoo调用foo函数,那么为了区分参数类型,callFoo也应该要写三个重载版本达成"完美转发"的效果:

void callFoo(const X& x) {
    foo(x); // 调用void foo(const X&)
}

void callFoo(X& x) {
    foo(x);// 调用void foo(X&)
}

void callFoo(X&& x) {
    foo(std::move(x));// 调用void foo(X&&), 注意std::move, x在callFoo函数域中是一个左值
    				// 在调用foo前,需要将其转化为右值
}

注意第三个重载版本,在调用foo前必须对x进行std::move,因为“move semantics is not automatically passed through”(见上一章的源码实例)

注意到我们编写三个callFoo函数,能否使用泛型只写一个函数模板?恐怕很难。假设你只编写下面callFoo函数的泛型版本

template<typename T >
void callFoo(T x) {
    foo(x);
}

在main函数中这样使用它:

int main() {
    const X const_x;
    X x;
    callFoo(const_x);
    callFoo(x);
    callFoo(X());
}

输出是:

foo(X& x) called
foo(X& x) called
foo(X& x) called

三个callFoo全部都调用了foo(X& x) 函数,没有实现完美转发。原因与模板推导有关,因为void callFoo(T x)表示值传递,因此参数始终被推导为X,不会有引用性,也不会保留const属性,详见《effective modern C++》条款1。

如果你想打个补丁:

void callFoo(T& x)
void callFoo(T&& x)

g++编译器会报错Call to 'callFoo' is ambiguous。况且,即时哪种编译器能通过编译,这样的写法一点都不"泛型", 你都写了这么多重载的泛型函数了,为什么还用泛型?而且,如果函数参数有2个,那么需要编写9个版本,如果参数有3个则要编写27个重载版本,可以预见,需要提供的重载版本数随着泛型参数的增加呈现指数级增长。

因此C++11 引入了两种特殊的机制,以在泛型编程中达成上述的“完美转发”效果:

  1. 万能引用
  2. std::forward

具体代码如下:

template<typename T>
void callFoo(T&& arg) { // 这是一个万能引用,而不是右值引用
    foo(std::forward<T>(arg)); // 使用std::forward保持参数的类型:如果arg在传入callFoo时是左值,则让其保持左值;否则将其转化为右值
}

只需要编写以上一个泛型版本的callFoo即可完成对foo函数参数的完美转发效果!

万能引用和std::forward

template<typename T>
void callFoo(T&& arg)  // 右值引用? 不,是万能引用

在泛型编程中,T&&看上去像是右值引用,但它其实是万能引用,它能够绑定所有的对象(包括const、non-const,左值、右值),以下调用都是合法的,而且,它们能够保持参数的常量性和值的类型(左值\右值)。

X v;
const X c;
callFoo(v);   // arg的型别 是 X&
callFoo(c);   // arg的型别 是 const X&
callFoo(X{}); // arg的型别 是 X&&
callFoo(std::move(v)); // arg的型别  是 X&&
callFoo(std::move(c)); // arg的型别  是 const X&&

概括而言,如果调用函数时传递的参数类型是左值,那么万能引用就绑定到一个左值,如果传递的参数是右值,那么万能引用就绑定到一个右值

注意区分万能引用和右值引用(详见modern effective C++条款24)

并不是形如 T&&的引用就是万能引用,T必须涉及类型推导时,T&&才是万能引用,典型场景就是在泛型编程中的T&&。

且即时在泛型编程场景下,const T&&不是万能引用,它只能绑定 const X&&

auto&& 也是一个万能引用,它也涉及型别推导

一句话:万能引用必须涉及型别推导

其余能够绑定任何类型的引用则是const&, 但是它没有保存参数是否是const的信息,而万能引用能保存参数是否为const的信息

为什么还需要std::forward呢?这与万能引用能够“绑定任何类型的对象”的特性有关:

  • 右值引用只能绑定可移动的对象,因此函数编写者100%确定他使用的函数参数能够被作用于std::move。

    void callFoo(X&& x) { //能够调用这个函数的参数一定也是右值引用
        foo(std::move(x)); // 因此能够毫无顾虑的调用std::move将其再次转化为右值
    }
    
  • 然而万能引用能够绑定任何对象,因此函数编写者不能确定他使用的参数是否在被std::move作用后是否保持原来的引用类型(“原来的类型”指的是函数作用域外,用来传递给函数形参的对象的类型),要实现完美转发不能使用std::move,只能使用std::forward。

    template<typename T>
    void callFoo(T&& arg) { // 这是一个万能引用,任何参数都能调用这个函数
        1. foo(std::move(arg)); // 如果使用std::move,则无条件将参数转化为右值,这是不对的!
        2. foo(std::forward<T>(arg));// 这样才合适,会先将arg转化为对应的类型,然后调用对应的函数版本
    }
    

std::forward的功能如下所述:

std::forward(arg)是一个有条件的std::move(arg), 即

  1. 如果arg是一个右值引用,则std::forward(arg)将会等效为std::move(arg)

  2. 如果arg是一个左值引用,则std::forward(arg)将会等效为 arg

通过万能引用和std::forward,我们就可以在泛型编程中实现完美转发:

template<typename T>
void callFoo(T&& arg) { // 这是一个万能引用,而不是右值引用
    foo(std::forward<T>(arg)); // 使用std::forward保持参数的类型:如果arg在传入callFoo时是左值,则让其保持左值;否则将其转化为右值
}
// 调用的函数foo有3个重载版本,见上小节

X v;
const X c;
callFoo(v);   // std::forward<T>(arg) => arg, 调用 foo(X&)
callFoo(c);   // std::forward<T>(arg) => arg, 调用 foo(const X&)
callFoo(X{}); // std::forward<T>(arg) => std::move(arg), 调用foo(X&&)
callFoo(std::move(v)); // std::forward<T>(arg) => std::move(arg), 调用foo(X&&)
callFoo(std::move(c)); // std::forward<T>(arg) => std::move(arg), 调用foo(cosnt X&)

接下来,将阐述完美转发能够运行的原理。

引用折叠

引用折叠是完美转发能够起作用的底层机制,但是在理解引用折叠之前,需要再了解一些模板型别推导的知识。

因为这里主要涉及完美转发,因此只讨论涉及万能引用的函数模板型别推导。比如这样的函数声明:

template<typename T>
void callFoo(T&& arg);

若以某个表达式expr调用它:

callFoo(expr);

编译器会进行两处类型推导,一是推导T的型别,二是推导T&&的型别(即arg的型别)

且由于函数参数使用的是万能引用,因此会对左值类型的expr有特殊处理方法。

expr是右值的情景,编译器是这样对T进行型别推导的:

  1. 若expr有引用型别,则先将引用部分忽略
  2. 然后,对expr的型别和 T&& 进行模式匹配,来决定T的型别

比如callFoo(1),此时expr为1,它是一个右值,它的类型为 int&&, 在与T&&进行模式匹配后,得到T的类型为int。最后自然地得到arg的型别为T&&,即arg是一个右值引用。

image-20230116202543018

②但如果expr是一个左值,编译器会将T推导为左值(至于为什么,我不是很清楚,个人倾向于将其解释为标准的规定)。

然后会将T&&的型别也就是arg的型别推导为左值! 例如:

int  x = 1;
callFoo(x); //expr型别为 int&, T的型别为 int&, arg的型别也是 int&

等等,T的型别被推导为int&, 那为什么arg的型别也是int&, 不应该是int& && 吗?

这就是引用折叠发挥作用的地方了,C++没有“引用的引用”这样的型别。因此如果你脑补了一个类的型别出现了3个或3个以上&符号,那么就一定得把它们转化成左值或者右值,具体的规则由C++标准如下规定:

image-20230116200714431

这里主要观察第二个规则,该规定就决定了上例的arg的型别被推导为左值引用,从int& && 折叠为 int&。

做个总结,当使用万能引用的模板参数时,编译器有一套特殊的类型推导规则:

  1. 如果传递的参数是一个右值,T的推导结果就是非引用型别,arg的推导结果就是右值引用型别
  2. 如果传递的参数是一个左值,T的推导结果就是左值引用型别,又由于"引用折叠"这个规定,于是arg的推导结果也是左值引用型别

个人看来,虽说引用折叠是完美转发的底层机制,但这其实就是C++标准会的一系列规定,是从需求出发的定制的一系列规定。

有关模板类型推导的其余内容请参考《effecive modern C++》条款1。

std::forward原理解析

有了引用折叠的这个概念后,理解std::forward的原理也就不难了。

下面从《effecive modern C++》条款28种摘录的代码片段,它展示了一种不完全符合C++标准的std::forward实现,但用来理解原理已经足够:

template<typename T>
T&& forward(typename remove_reference<T>::type& param){
    return static_cast<T&&>(param);
}

看到std::forward的底层实现就是一个static_cast,于此同时万能引用与引用折叠在这里默默起了很大的作用。下面,分别阐述使用左值和右值进行forward调用的参数推导过程。

仍然用上一节的例子进行说明:

template<typename T>
void callFoo(T&& arg){
    foo(std::forward<T>(arg)); 
}
// 情况一,传递左值
int x = 1;
callFoo(x);

// 情况二, 传递右值
callFoo(1)

①如果传递给callFoo的参数原本为左值引用的int类型,那么按照上一节的参数推导规则,T将被推导为 int&,注意这里的类型推导指callFoo这个函数的类型推导,forward将不进行类型推导,因为在执行forwar调用时已经指明了具体类型(尖括号中的T)。将int& 插入forward模板中得到下面的代码:

int& && forward(typename remove_reference<int&>::type& param) {
    return static_cast<int& &&>(param);
}

其中的remove_reference<int&>::type,看名字就可以知道这就是将<>内的型别去掉引用部分后得到的型别。在这里就是int,最后加上末尾的&,那么param的型别就被推导为int&。

最后再加上引用折叠的规则,我们得到:

int& forward(int& param) {
    return static_cast<int&>(param) // static_cast 将参数转化为左值引用,实际上没什么作用,因为param已经是左值引用了
}

②如果传递给callFoo的参数原本为右值引用的int类型,T将被推导为int,它不是一个引用类型,将其插入forward模板得到:

int&& forward(int& param) {
    return staric_cast<int&&>(param); // static_cast 将左值引用类型的参数转化为右值引用
}

这里没有发生引用折叠。

总结:

  1. 当传递参数为左值引用时,forward将返回左值引用
  2. 当传递参数为右值值引用时,forward将返回右值引用

这恰好就是完美转发需要的组件!

智能指针

参考资料:

总览

C++ 11 总共有4种智能指针, std::auto_ptr std::unique_ptr std::shared_ptr std::weak_ptr

std::auto_ptr 是个从C++98残留下来的特性,在C++17中,已经被声明为depracated了

std::unique_ptr 借助右值引用使得移动操作成为可能,解决了auto_ptr的问题

std::weak_ptr则可以用来解决std::shared_ptr的循环引用的问题。

std::auto_ptr

首先看看 auto_ptr, 了解我们为什么C++弃用它,它有什么不足之处。

我们把动态分配的堆内存的释放任务交给这些类,当这些类的生命周期结束时会自动调用析构函数,析构函数常常有delete之类的操作释放这些动态分配的内存。这样的好处是,指针管理维护对我们造成的心智负担会大大减少。

我们写一个Auto_Ptr类,模拟指针的操作,并且在析构函数中 对自己维护的指针进行delete

template <typename T>
class Auto_Ptr {
    public:
        Auto_Ptr(T* ptr) : ptr_(ptr){ }
        ~Auto_Ptr() {
            delete ptr_;
        }
        // 重载下面两个运算符,使得类能够像指针一样运作
        T& operator*() {
            return *ptr_;
        }
    	
        T* operator->() {
            return ptr_;
        }
    private:
        T* ptr_;
};

class A {
    public:
    A() {
        std::cout <<"class A construct!\n";
    }
    ~A() {
        std::cout << "class A destroyed";
    }
    int attr_a = 2;
};z

int main() {
    Auto_Ptr<A> autp (new A());
    std::cout << autp->attr_a << std::endl;// autO的行为就像是一个指针
    std::cout << (*autp).attr_a << std::endl;
    return 0; // Auto_Ptr类自动delete,释放动态分配的内存
}

能够得到下面的输出信息 :

class A construct!
2
2
class A destroyed

这样一个能够自动释放动态内存的类就与智能指针类的思想类似,但是Auto_ptr现在有两个问题

  • 不能pass by value , 否则,意味有两个以上的autp_ptr中的指针指向了同一块内存,这两个autp_ptr结束生命周期时一定会调用析构函数,但无论以哪种顺序调用析构函数,都会在同一个指针上调用两次以上delete操作,segment fault!。我们可以手动禁止Auto_Ptr的复制函数, 这样倒是可以解决这个的问题。

  • 但禁止Auto_Ptr的复制函数后,如何编写一个返回Auto_Ptr对象的函数?:

    Auto_Ptr generateResource() // delete了Auto_Ptr的复制构造函数后,不能这样写了
    {
         Resource* r{ new Resource() };
         return Auto_ptr1(r);// 编译器报错
    }
    

好,那我们不删除复制函数,而是改进它: 复制函数不仅仅简单拷贝指针, 而是将指针的所有权从源对象“转移”到目标对象

template <typename T>
class Auto_Ptr {
    public:
		...
        Auto_Ptr( Auto_Ptr& source) {
            ptr_ = source.ptr_;
            source.ptr_ = nullptr;
        }

        Auto_Ptr& operator=(Auto_Ptr& source) {
            if (&source == this) {
                return *this;
            }
            delete ptr_;
            ptr_ = source.ptr_;
            source.ptr_ = nullptr; // 将源对象的指针进行delete
            return *this;
        }
		...
            
        bool isNull() const { return ptr_ == nullptr; }
};

至少我们现在能够 对函数参数进行passby value 了, 但是我们很容易又造成访问野指针的错误,因为传统观念来看,值传递的语义就是“复制”,但是我们改造了复制函数,实际上执行是“移动”。而且从函数的声明可以看到,我们传入的是non-const参数,表示我们要修改它,这和传统的拷贝函数大不相同!

void DoSomeThing(Auto_Ptr<A> s) { // pass by value 并进行相应操作
    std::cout << s->attr_a;
}
int main() {
    
    Auto_Ptr<A> res1 (new A());
    DoSomeThing(res1); // 按值传递,成功。但是res1这个变量已经被"移动"了
    std::cout << res1->attr_a <<std::endl; //再次使用res1,crash !
}

总结

autpptr是C++尝试“移动语义”的开始,但是总是表现出将资源从一个object转移到另一个object的行为

autp_ptr的缺点:

  • 使用复制构造\赋值函数模拟移动语义,非常容易造成野指针现象。也不能和标准库很好地一起工作,比如一个存放auto_ptr的vector容器,对它使用std::sort函数,sort函数在某步骤中会选取序列中的某一个并保存一个局部副本

    ...
    value_type pivot_element = *mid_point;
    ...
    

    算法认为在这行代码执行完之后,pivot_element 和 *mid_point是相同的,但是因为auto_ptr的拷贝操作是对移动操作的模仿,当执行完这行代码后,mid_point所指向的内存是不确定的。最后算法正确性就受到了破坏

  • auto_ptr中的析构函数总是使用delete ,所以它不能对动态分配的数组做出正确的释放操作(而unique_ptr可以自定义deleter)

核心问题 :

  • 如果我们在想让对象在拷贝的时候能够被拷贝,移动的时候能够被转移控制权,那么就一切好办了。这就是为什么C++提出了“移动语义”(好家伙,C++11的新特性好多都和移动语义相关)
  • C++11提出右值引用,很方便地表达了移动语义,以此带来了表示独占的、只能被移动而不能被拷贝的unique_ptr

std::unique_ptr

unique_ptr的大小与裸指针相同(如果不使用函数指针自定义删除器),这是智能指针中最常用的。

关于unique_ptr的大小,库函数使用了空基类优化的技巧,具体实现方式可以参考这篇文章

C++11引进了移动语义,能够将object的移动或拷贝以更清楚的方式区分,也多出了两种特殊的成员函数, 移动构造和移动赋值。下面用新的成员函数改造之前的Auto_Ptr。其实逻辑和之前实现的拷贝函数是一样的,但这里的逻辑是移动逻辑,不应该放在拷贝函数中。

...
// 参数是右值引用, 且非const
Auto_Ptr( Auto_Ptr&& source) {
    ptr_ = source.ptr_;
    source.ptr_ = nullptr;
}
// 参数是右值引用,非const
Auto_Ptr& operator=( Auto_Ptr&& source) {
    if (&source == this) {
        return *this;
    }
    delete ptr_;
    ptr_ = source.ptr_;
    source.ptr_ = nullptr;
    return *this;
}
...

参数是non-const的右值引用,因为是右值引用,所以不用加const 属性, “右值”表示这个值的生命周期很短暂,无所谓我们改不改变它。

最后我们删除拷贝函数

 Auto_Ptr(const Auto_Ptr& source) = delete;
 Auto_Ptr& operator=(const Auto_Ptr& source) = delete;

这样的AutoPtr类就非常类似标准库的unique_ptr

unique_ptr只允许从右值转移资源,但不能从左值拷贝资源,我们使用std::move将左值转变为右值后就可以了。但是被转变的值已经不能使用了,既然你已经move了他,那就说明被move的值可以被转移,编译器是假设程序员知道这件事的,所以我们之后再使用已经被move的变量而后导致未定义行为,责任在程序员而不是编译器。

Auto_Ptr<A> getResource() {
    A* res_f = new A();
    
    return Auto_Ptr<A>(res_f);
}
int main() {
    
    Auto_Ptr<A> res1 (new A());
    //Auto_Ptr<A> res2 (res1);// 报错
    Auto_Ptr<A> res2 (std::move(res1)); // 将左值cast为右值,编译通过
    Auto_Ptr<A> res3(getResource()); // 传递临时对象,即一个右值,编译通过
    DoSomeThing(getResource());
    DoSomeThing(std::move(res3)); // 也能值传递了 , 但是 res3 在这行之后就已经被转移了
    std::cout << "res3 is " << (res3.isNull() ? "null\n" : "not null\n"); // res3 is null
    std::cout <<(*res3).attr_a << std::endl; // 使用已经被转移的变量, crash!
    return 0;
}

最后将上面的代码修改整合,得到一份简单的Unique_Ptr实现:

template<typename T> 
class Unique_Ptr {
private:
    // 原始指针
    T* resource_;

public:
    // unique_ptr是只移的,因此删除赋值函数
    Unique_Ptr(const Unique_Ptr&) = delete;
    Unique_Ptr& operator=(const Unique_Ptr&) = delete;
    
    // 构造函数
    explicit Unique_Ptr(T* raw_ptr): resource_(raw_ptr) { } // explicit防止隐式转换
    // 移动构造函数
    Unique_Ptr(Unique_Ptr&& other):resource_(other.resource_) {
        other.resource_ = nullptr;
    }
     // 移动赋值函数
    Unique_Ptr& operator=(Unique_Ptr&& other) {
        if (&other != this) { // 注意自赋值的情况
            delete resource_;
            resource_ = other.resource_;
            other.resource_ = nullptr;
        }
        return *this;
    }
    // 析构函数
    ~Unique_Ptr() {
        if (resource_) {
            delete resource_;
            resource_ = nullptr;
        }
    }
    // 解引用符号 * 重载
    T& operator*() const{
        return *resource_;
    }
	// ->符号重载
    T* operator->() const{
        return resource_;
    }
};

unique_ptr的使用场景

作为工厂函数的返回值,unique_ptr能够方便高效地、无感地转换成shared_ptr。工厂函数并不知道调用者是对器返回的对象采取专属所有权好,还是共享所有权更合适。

// 函数声明返回unique_ptr
template<typename...TS>
std::unique_ptr<Investment>
makeInvestment(Ts&&... param);

// 用户程序可以取得一个shared_ptr<Investment>, 其中的转换会默认进行
std::shared_ptr<Investment> a = makeInvestment(...);

std::shared_ptr

与unique_ptr不同,share_ptr对象能够与其他share_ptr对象共同指向同一个指针,内部维护一个引用计数,每多一个对象管理原指针,引用计数(reference count)就加一,每销毁一个share_ptr,引用计数减一,最后一个被销毁的shared_ptr对象负责对原始指针进行delete操作

从底层数据结构看(下图源自《effective modern c++》),shared_ptr除了保存原始指针外,还会保存一个指向控制块的指针,所以一般情况下(unique_ptr没有使用函数指针当作自定义删除器)shared_ptr的大小会比unique_ptr大两倍。控制块是一动态分配在堆内存中的,其中有引用计数、弱计数、以及其他数据(比如自定义deleter、原子操作相关的数据结构),弱计数是统计指向T object 的weak_ptr数量,这个计数不影响T object的析构,当引用计数 = 0时,T object 就会被销毁,不会管弱计数(weak count)。

image-20220915215627545

shared_ptr 能够被移动也能够被拷贝,被拷贝时引用计数+1,这个引用计数使用原子变量保证线程安全(但仅仅保证RefCount的线程安全性),被移动时则不需要。因此考虑效率时,如果能够移动构造一个shared_ptr那就使用移动,不要使用拷贝。

sharedptr的线程安全性?

sharedptr使用atomic变量使得计数器的修改是原子的(即上图的RefCount是原子的),但是sheared_ptr这个类本身不是线程安全的,因为整个SharedPtr对象有两个指针,复制这两个指针的操作不是原子的!更别说sharedptr管理的对象(上图的T Object)是否有线程安全性了,除非这个对象本身有锁保护,否则不可能通过只套一层sharedptr的封装来实现线程安全性。

具体参考:

关于std::atomic?

C++能够提供原子操作是因为多数硬件提供了支持,比如x86的lock指令前缀,它能够加在INC XCHG CMPXCHG等指令前实现原子操作。

std::atomic比std::mutex快,是因为std::mutex的锁操作会涉及到系统调用,比如在linux上会调用futex系统调用,在某些情况下可能陷入内核。

参考了这个回答

从效率上考虑,优先使用make_shared而不是直接new创建shared_ptr

shared_ptr类有两个指针,一个指向要管理的对象,一个指向控制块。

如果使用new来创建shared_ptr:

std::shared_ptr<SomeThing> sp(new SomeThing);

编译器则会进行两次内存分配操作,一次为SomeThing的对象分配,一次为控制块分配内存。

如果使用make_shared创建:

auto sp(std::make_shared<SomeThing>())

编译会只会进行一次内存分配,对象与控制块是紧挨着的。

实现一个简单的Shared_Ptr, 其余测试代码见github仓库

// 模拟控制块类
class Counter {
    public:
        std::atomic<unsigned int> ref_count_;

        Counter():ref_count_(0){}
        Counter(unsigned int init_count):ref_count_(init_count){ }
};

// Shared_Ptr模板类
template<typename  T>
class Shared_Ptr{
    private:
        Counter* count_;
        T* resource_;

        void release() {
            if (count_ && resource_) { // 注意这里应该判断count_是否为nullptr,可能已经被移走了
                if (--count_->ref_count_== 0) {
                    delete resource_;
                    delete count_;
                    resource_ = nullptr;
                    count_ = nullptr;
                }
            }
        }
    public:
        // 构造函数
        explicit Shared_Ptr():count_(new Counter(0)),resource_(nullptr) { }
        explicit Shared_Ptr(T* raw_ptr):count_(new Counter(1)),resource_(raw_ptr) { }
        Shared_Ptr(std::nullptr_t nPtr) {
            release();
            resource_ = nPtr;
            count_ = nPtr;
        }
        // 析构函数

        ~Shared_Ptr() {

            release();
        }
        // 复制构造函数
        Shared_Ptr(const Shared_Ptr& other) {
            resource_ = other.resource_;
            count_ = other.count_;
            count_->ref_count_++;
        }
        // 赋值构造函数
        Shared_Ptr& operator=(const Shared_Ptr& other) {
            if (&other != this) {
                // delete resource_; // 这里有问题,能直接delete吗?
                // delete count_;
                release();

                resource_ = other.resource_;
                count_ = other.count_;
                count_->ref_count_++;
            }
            return *this;
        }

        // 移动构造函数
        // 注意将被移动对象的资源置空
        Shared_Ptr(Shared_Ptr&& other):resource_(other.resource_), count_(other.count_) {

            other.resource_ = nullptr;
            other.count_ = nullptr;
        }
        // 移动赋值函数
        Shared_Ptr& operator=(Shared_Ptr&& other) {
            // 注意将被移动对象的资源置空
            if (this  != &other) {
                release(); // 释放资源
                
                resource_ = other.resource_;
                other.resource_ = nullptr;

                count_ = other.count_;
                other.count_ = nullptr;
            }
            return *this;
        } 
};

std::weak_ptr

std::weak_ptr是std::shared_ptr的一种补充,它不是独立出现的,std::weak_ptr通常通过unique_ptr来初始化,使用了与std::shared_ptr同一个控制块,但是不会增加refcout只会增加weakcount。它既不能执行提领操作,也没有->操作.

可以通过weak_ptr来构造shared_ptr(调用lock成员函数),如果shared_ptr所指涉的对象已经被销毁,那么转换为空指针。这样在使用某个智能指针前,可以先使用weakptr检测智能指针所指涉的对象是否已经被销毁(调用expire成员函数), 这是weak_ptr操作原对象的唯一方法(即转换成shared_ptr)

关于控制块与智能指针所管理的对象的内存释放时机

  • 如果使用make_shared来创建sharedptr,由于只进行了一次内存分配,那么得等到weakcount = 0时才会回收这块内存

  • 如果使用new来创建sharedptr,这里分别进行了两次内存分配,那么当refcount = 0时,智能指针所管理的对象的内存可以立即回收,但是控制块的内存还是得等到weakcount = 0时才会回收

弱指针的应用场景

  1. 解决循环引用的资源泄漏问题
  2. 带有缓存的工厂函数:函数返回sharedptr,工厂内部使用weak_ptr指涉客户所要创建的对象
  3. 观察者设计模式

为一个类设计一个成员函数,返回一个shared_ptr智能指针,指针指向自己?

关于这个话题,可以先看看cppreference的解释,其中包括错误和正确的演示:std::enable_shared_from_this - cppreference.com

错误的做法是:

struct Bad
{
    std::shared_ptr<Bad> getptr()
    {
        return std::shared_ptr<Bad>(this);
    }
    ~Bad() { std::cout << "Bad::~Bad() called\n"; }
};

为什么?因为getptr成员函数会再分配一个控制块来管理Bad的某个对象,如果这个对象已经被一个shareptr管理的话,那么就可能发生double free运行时错误。具体一点,就如下面这段代码:

// Bad, each shared_ptr thinks it's the only owner of the object
std::shared_ptr<Bad> bad0 = std::make_shared<Bad>();
std::shared_ptr<Bad> bad1 = bad0->getptr();
// UB: double-delete of Bad

第一个语句调用make_shared会分配一个控制块,第二个语句调用通过成员函数再次分配一个控制块,但是这两个控制块都控制同一个对象指针,最后一定会对对象进行两次的free,从而引发double free错误。

正确的做法是继承std::enable_shared_from_this,调用它提供的父类方法来获取指向自身的sharedptr:

class Good : public std::enable_shared_from_this<Good>
{
public:
    std::shared_ptr<Good> getptr()
    {
        return shared_from_this();
    }
};
// 正确的食用方式:
std::shared_ptr<Good> good0 = std::make_shared<Good>(); // 注意必须已经有一个sharedptr才可以,否则抛异常,详见cppreference的对应代码
std::shared_ptr<Good> good1 = good0->getptr();

那么enable_shared_from_this是怎么样避免double free错误的呢?猜一下就能知道它可能使用了weakptr

template<class _Tp>
class _LIBCPP_TEMPLATE_VIS enable_shared_from_this
{
    mutable weak_ptr<_Tp> __weak_this_; 
    // ...

详细分析可以参考这篇博客

lambda表达式

本质

lambda的本质是一个仿函数(functor),编译器看到lambda表达式后会产生一个匿名class,这个class重载了()操作符

比如下面这个仿函数:

class X {
    int a = 1;
public:
    void operator()(int b) {
        printf("a + b = %d\n", a + b);
    }
};

X x_functor;

它的作用效果与下面lambda表达式相同:

auto x_lambda = [a = 1](int b) {printf("a + b = %d\n", a + b);};

两者的调用方式和调用一个函数的方式相同:

x_functor(1);
x_lambda(1);

编译期,编译器遇到lambda表达式则会生成一个匿名仿函数类型(closure type);运行期,当使用lambda表达式时,则根据编译器生成的匿名仿函数类型创建一个对象,该对象本质就是functor对象。

语法

lambda表达式的语法如下:

[捕获值] (参数列表) ->返回类型 {函数体}
  • 捕获值
    • 能够捕获本lambda表达式所处作用域中的局部变量(不包括类的成员变量)或this指针,使其能够在{}内的函数体中可以被使用
    • 捕获方式有按值和按引用两种
    • 可以空着,这相当于生成了一个没有成员变量的仿函数
  • -> 返回类型
    • 通常可不写,编译器从函数体中自动推导

其中关于捕获的注意点最多

  • 按值和按引用捕获的区别,

    int main()
    {
        int x = 42;
        
        auto byvalue = [x] ( ) // 按值捕获局部变量x,记住当lambda表达式被evaluated时,值就已经被捕获了
        {
            std::cout << "Hello from a lambda expression, value = " << x << std::endl;
        }; 
        
        auto byref = [&x] ( ) // 按引用捕获局部变量x
        {
            std::cout << "Hello from a lambda expression, value = " << x << std::endl;
        };
        x = 7;
        byvalue(); // 42, 按值捕获且在lambda表达式被创建时就被捕获,因此不受影响
        byref();   // 7 , 按引用捕获因此受影响
    }
    
  • 按值捕获的变量是只读的,如果要修改它,则应该在参数列表后加上mutable关键字

    auto myLamb = [x] ( ) mutable { return ++x; };
    
  • 避免默认捕获模式,详见effecttive modern C++条款31

    • 按引用的默认捕获方式容易造成指针空悬
    • 看似能够捕获成员变量,实际上则是捕获了this指针,因此也容易造成指针空悬
  • 默认捕获不能捕获全局变量!

    int g = 10;
    auto kitten = [=]() { return g+1; }; // 默认按值捕获,但是编译器发现g是全局变量,根本不需要捕获
    auto cat = [g=g]() { return g+1; };  // 广义的按值捕获则可能得到预期结果
    int main() {
     g = 20;
     printf(%d %d\n", kitten(), cat());// 21 11
    }
    
  • 最好都是写成广义捕获的形式,这是C++14支持的特性

    auto cat = [g=g]() { return g+1; }; // 按值捕获g 
    auto dog = [&g=g]() { return g+1; }; // 按引用捕获g
    

    注意,= 号两边的g是不同的,左边的g是lambda表达式所处作用域的局部变量,右边的g则是编译器为lambda表达式生成的functor中的成员变量

四大转换

参考资料

https://cplusplus.com/doc/tutorial/typecasting/

https://www.quora.com/How-do-you-explain-the-differences-among-static_cast-reinterpret_cast-const_cast-and-dynamic_cast-to-a-new-C++-programmer

https://en.wikibooks.org/wiki/C%2B%2B_Programming/Programming_Languages/C%2B%2B/Code/Statements/Variables/Type_Casting

《深度理解C++对象模型》中文版 P310

C++相比于C语言多出了4种转换,并且也兼容C风格的转换。C风格的转换几乎可以转换任何类型,简单方便的同时增大了出错地可能性。

// 两种通用的转换方式,容易出错
double x = 10.3;
int y;
// C++存在两种通用类型的转换,第二种则是C风格的转换,第一种和第二种的作用相同
y = int (x);    // functional notation, 
y = (int) x;    // c-like cast notation

C风格的转换能够做以下所有的转换 :

  1. Between two arithmetic types
  2. Between a pointer type and an integer type
  3. Between two pointer types
  4. Between a cv-qualified and cv-unqualified type (简单说就是const类型与非const类型的转换)
  5. A combination of (4) and either (1), (2), or (3)

C风格转换的缺点 :

  1. They allows casting practically any type to any other type, leading to lots of unnecessary trouble - even to creating source code that will compile but not to the intended result.
  2. The syntax is the same for every casting operation, making it impossible for the compiler and users to tell the intended purpose of the cast.
  3. Hard to identify in the source code.

C++提供了另外四种转换:

dynamic_cast

dynamic_cast :只能转换指向class的指针或引用(通常涉及多态),能够确保转换的结果指向目标指针类型的完整对象( Its purpose is to ensure that the result of the type conversion points to a valid complete object of the destination pointer type.)。

dynamic_cast能够将类指针向上转型(派生类指针指向基类指针),这和static_cast相似,不需要被转换的类拥有虚函数,而且C++标准规定在这种情况下产生与static_cast一致的底层代码。如下所示,没有产生编译错误:

class A {

};

class B : public A{
};
int main() {
    B* b = new B();
    A* d = dynamic_cast<A*>(b); // 子类指针转向父类指针
}

也可以将执行向下转型(将基类型指针转换成派生类型的指针),但是满足两个条件转换才能成功 :

  1. 基类必须有虚函数,即只对那些展现“多态”的类型,才可能执行向下转换。否则编译器报错:

    class A{
    
    };
    class B : public  A {
    };
    int main() {
        A* a = new A();
        B* c = dynamic_cast<B*>(a);
        // 编译器报错: cannot dynamic_cast ‘a’ (of type ‘class A*’) to type ‘class B*’ (source type is not polymorphic)
    }
    
    

    最起码,父类具有虚函数才可以,这样父子类都有了虚函数,也就都有个运行时类信息,才能通过编译:

    class A {
    public:
        virtual ~A() {
        }
    };
    class B : public A{
    
    };
    int main() {
        A* a = new A();
        B* c = dynamic_cast<B*>(a);
    }
    
  2. 但是通过编译不代表转换成功,如果转换后的对象指针确实是目标对象的指针,那么转换成功。但如果dynamic_cast向下转换失败则会返回nullptr(指针之间的转换)或者抛出异常(引用之间的转换)。程序员通过检查指针,就可以知道向下转型是否成功。

    class A {
    public:
        virtual ~A() {}
    };
    class B : public A{
    };
    class C {
    public:
        virtual ~C() {}
    };
    int main() {
    
        C* c_ptr = new C();
        A* a = dynamic_cast<A*>(c_ptr);
        printf("a = %p\n", a);  // a = (nil), 说明转换不成功
    }
    

dynamici_cast使用场景:

using namespace std;
class Base { virtual void dummy() {} };
class Derived: public Base { int a; };
int main () {
  try {
    Base * pba = new Derived;
    Base * pbb = new Base;
    Derived * pd;

    pd = dynamic_cast<Derived*>(pba); // 转换成功
    if (pd==0) cout << "Null pointer on first type-cast.\n";

    pd = dynamic_cast<Derived*>(pbb); // 这个转换不会成功但不会抛出异常,只会返回nullptr
    if (pd==0) cout << "Null pointer on second type-cast.\n";

  } catch (exception& e) {cout << "Exception: " << e.what();}
  return 0;
}
// 结果
Null pointer on second type-cast.

关于dynamic_cast的实现原理,看了《深度理解C++对象模型》后了解到编译器会将对象的运行时类型信息(RTTI)指针连同虚函数指针一起放在虚函数表中(RTTI的指针在函数指针的上方),这也就是为什么不具多态意图的class不能执行dynamic_cast的原因,因为这些类没有虚函数,也就没有虚函数表,那也没有地方存放类型信息。

static_cast

static_cast : 能够做与dynamic_cast相似的工作(即类层次指针间向上/向下转型),但是编译器不会在运行期检查(向下)转换后的object指针是否为目标object指针,因此转换是否成功是由开发人员自己保证的。static_cast用于有直接或间接关系的指针或引用之间转换。

没有继承关系的指针不能用static_cast转换,可以考虑使用reinterpret_cast。

当然static_cast除了可以做类层次结构指针之间的转换外还可以做其他很多其他类型的转换:

  • 将void指针转换成任何其他类型的指针,但是会检查void*指针是否由同一类型的指针转换而来(存疑!)(C风格的转换和reinterpret_cast不会检查)
  • 用于基本数据类型之间的转换

static_cast转换两个没有关系的类指针时会产生编译错误:

class A {

};

class B {

};
int main() {

    A* a = new A();
    B* b = new B();
    B* c = static_cast<B*>(a); // compiler error ! invalid static_cast from type ‘A*’ to type ‘B*’
    A* d = static_cast<A*>(b); // compiler error ! 

}

如果B继承自A或者A继承自B,就不会产生编译时错误

class B : public A{

};

“子类指针转换成父类指针,使用static_cast、dynamic_cast两种中的任意一种都会产生相同的代码”。接下来验证这件事

为了不至于太简单,我在B中加了一个虚函数,这样当子类转化成父类时,编译器将调整this指针跳过vptr

class A {
    public:
        int a = 1;
};

class B : public A{
    public:
        int b = 1;
        virtual int fun1() {
            return 1;
        };
};

int main() {
    B* b_ptr = new B();
    A* a_ptr = static_cast<A*>(b_ptr);
    A* a_ptr2 = dynamic_cast<A*>(b_ptr);
}

实验的编译器版本为g++7.5:

g++ cast.cpp -o cast && objdump -d cast > cast.asm

然后找到返汇编文件中的关于cast的相关代码,我删除了一些无关代码:

 8e3:	48 8b 45 d8          	mov    -0x28(%rbp),%rax # 使用static_cast进行转换
 8e7:	48 83 c0 08          	add    $0x8,%rax
 8f2:	48 89 45 e0          	mov    %rax,-0x20(%rbp)  

 8fd:	48 8b 45 d8          	mov    -0x28(%rbp),%rax # 使用dynamic_cast进行转换
 901:	48 83 c0 08          	add    $0x8,%rax
 90c:	48 89 45 e8          	mov    %rax,-0x18(%rbp)

可以看出,dynamic_cast进行转换的逻辑与static_cast相同,换句话说,这里的dynamic_cast根本没有进行“动态”转换。

额外话题:static_cast与C风格转换的区别

reinterpret_cast

reinterpret_cast能够将任何类型的指针转换成任意类型,即使这两个类型没有任何关系(主要是没有继承关系)。它只是在两个指针之间简单地执行二进制拷贝,不会进行任何检查。也可以将指针转换成整型。

reinterpret_cast几乎与C风格的转换可以做同样多的事,但它依然不能将const的类型的object转换成non const, 不止reinterpret_cast,以上三种C++的类型转换都不能将object的const属性去除(但是C风格的转换不管,这也是它不安全的原因之一),唯一能够将const对象转换成非const的C++风格的转换是下面的const_cast

reinterpret_cast与C风格转换的区别

const_cast

如上所说,这是C++提供的4种转换种的唯一一个可以"抹除"object const属性的转换方式

实战示例

来自CMU15445lab

reinterpret_cast在lab源码中出现的频率很高, 比如 :

  reinterpret_cast<Page *>(bucket_page)->WLatch();
	// modify bucket
  reinterpret_cast<Page *>(bucket_page)->WUnlatch();

BucektPage 与 Page根本没有继承关系所以使用reinterpret_cast转换,但是这对Page中的成员的顺序由要求

下面是Page的成员组成:

class Page {
    ...
   /** The actual data that is stored within a page. */
  char data_[PAGE_SIZE]{};
  /** The ID of this page. */
  page_id_t page_id_ = INVALID_PAGE_ID;
  /** The pin count of this page. */
  int pin_count_ = 0;
  /** True if the page is dirty, i.e. it is different from its corresponding page on disk. */
  bool is_dirty_ = false;
  /** Page latch. */
  ReaderWriterLatch rwlatch_;
} 

其中data_就是实际page的开始地址,我们使用reinterpret_cast把char* 转换为 BucketPage*

bucket_page =
      reinterpret_cast<HASH_TABLE_BUCKET_TYPE *>(buffer_pool_manager_->FetchPage(bucket_page_id)->GetData());

按照Struct成员再内存中的分布,我们可以得到下面的示意图

image-20220903155009273

编译器由低地址向高地址取得内存中的内容并将它解释为对应的类,无论是Page还是BucketPage都是合法的不会产生错误。

但如果 data_声明在最后会怎样?

image-20220903155829342

因为使用reinterpret_cast,所以编译器不会进行任何检查,只会从低地址一直向上解释 length of data_ 个字节数为BucketPage, 很显然这是错误的

posted @ 2023-02-09 20:49  别杀那头猪  阅读(305)  评论(0编辑  收藏  举报