• 《深度探索C++对象模型》阅读笔记 第二章 构造函数语意学


    0、通常很多C++程序员存在两种误解:

    • 没有定义默认构造函数的类都会被编译器生成一个默认构造函数。
    • 编译器生成的默认构造函数会明确初始化类中每一个数据成员。

    在读《深度探索C对象模型》之前,我一直停留在上述二种误解上,所幸的是
    Lippman为我破除了藩篱。下面的部分我将随《深度探索C对象模型》对C++默
    认构造函数一探究竟。

    C++标准规定:如果类的设计者并未为类定义任何构造函数,那么会有一个默认
    构造函数被暗中生成,而这个暗中生成的默认构造函数通常是不做什么事的(无
    用的),下面四种情况除外。

    换句话说,有以下四种情况编译器必须为未声明构造函数的类生成一个会做点事
    的默认构造函数
    。我们会看到这些默认构造函数仅“忠于编译器”,而可能不会按
    照程序员的意愿程效命。

    1.包含有带默认构造函数的对象成员的类

    若一个类X没有定义任何构造函数,但却包含一个或以上定义有默认构造函数的
    对象成员,此时编译器会为X合成默认构造函数,该默认函数会调用对象成员的
    默认构造函数为之初始化。如果对象的成员没有定义默认构造函数,那么编译器
    合成的默认构造函数将不会为之提供初始化。例如类A包含两个数据成员对象,
    分别为:string strchar *Cstr,那么编译器生成的默认构造函数将只提
    供对string类型成员的初始化,而不会提供对char*类型的初始化。

    假如类X的设计者为X定义了默认的构造函数来完成对str的初始化,形如:
    A::A(){Cstr=”hello”};因为默认构造函数已经定义,编译器将不能再生成一
    个默认构造函数。但是编译器将会扩充程序员定义的默认构造函数——在最前面插
    入对初始化str的代码。若有多个定义有默认构造函数的成员对象,那么这些成员
    对象的默认构造函数的调用将依据声明顺序排列。

    2.继承自带有默认构造函数的基类的类

    如果一个没有定义任何构造函数的类派生自带有默认构造函数的基类,那么编译
    器为它定义的默认构造函数,将按照声明顺序为之依次调用其基类的默认构造函
    数。若该类没有定义默认构造函数而定义了多个其他构造函数,那么编译器扩充
    它的所有构造函数——加入必要的基类默认构造函数。另外,编译器会将基类的默
    认构造函数代码加在对象成员的默认构造函数代码之前。

    3.带有虚函数的类

    带有虚函数的类,与其它类不太一样,因为它多了一个vptr,而vptr的设置是由
    编译器完成的,因此编译器会为类的每个构造函数添加代码来完成对vptr的初始
    化。

    4.带有一个虚基类的类

    在这种情况下,编译器要将虚基类在类中的位置准备妥当,提供支持虚基类的机
    制。也就是说要在所有构造函数中加入实现前述功能的的代码。没有构造函数将
    合成以完成上述工作。

    总结

    1. 如果满足多态,编译器会调用指针指向对象的虚函数,而与指针的类型无关。如果不满足多态,编译器会直接根据指针的类型去调用虚函数
    2. 简单来讲编译器会为构造函数做的一点事就是调用其基类或成员对象的默
      认构造函数,以及初始化vprt以及准备虚基类的位置。

    总的来说,编译器将对构造函数动这些手脚:

    • 如果类虚继承自基类,编译器将在所有构造函数中插入准备虚基类位置的代
      码和提供支持虚基类机制的代码。
    • 如果类声明有虚函数,那么编译器将为之生成虚函数表以存储虚函数地址,
      并将虚函数指针(vptr)的初始化代码插入到类的所有构造函数中。
    • 如果类的父类有默认构造函数,编译将会对所有的默认构造函数插入调用其
      父类必要的默认构造函数。必要是指设计者没有显示初始化其父类,调用顺
      序,依照其继承时声明顺序。
    • 如果类包含带有默认构造函数的对象成员,那么编译器将会为所有的构造函
      数插入对这些对象成员的默认构造函数进行必要的调用代码,所谓必要是指
      类设计者设计的构造函数没有对对象成员进行显式初始化。成员对象默认构
      造函数的调用顺序,依照其声明顺序。
    • 若类没有定义任何构造函数,编译器会为其合成默认构造函数,再执行上述
      四点。

    需要说明的是,从概念来上来讲,每一个没有定义构造函数
    的类都会由编译器来合成一个默认构造函数,以使得可以定义一个该类的对象,
    但是默认构造函数是否真的会被合成,将视是否有需要而定。C++ standard 将
    合成的默认构造函数分为 trivial 和 notrivial 两种,前文所述的四种情况对
    应于notrivial默认构造函数,其它情况都属于trivial。对于一个trivial默认
    构造函数,编译器的态度是,既然它全无用处,干脆就不合成它。在这儿要厘清
    的是概念与实现的差别,概念上追求缜密完善,在实现上则追求效率,可以不要
    的东西就不要。

    5、命名返回值优化

    对于一个如foo()这样的函数,它的每一个返回分支都返回相同的对象,编译器有可能对其做Named return Value优化(下文都简称NRV优化),方法是以引用的方式传入一个参数 result 取代返回对象。

    X foo() //原型
    { 
        X xx; 
        if(...) 
            return xx; 
        else 
            return xx; 
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    优化后的foo()result取代xx

    void  foo(X &result)
    {
        result.X::X();
        if(...)
        {
            //直接处理result
            return;
        }
        else
        {
            //直接处理result
            return;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    对比优化前与优化后的代码可以看出,对于一句类似于X a = foo()这样的代
    码,NRV优化后的代码相较于原代码节省了一个临时对象的空间(省略了xx),同
    时减少了两次函数调用(减少xx对象的默认构造函数和析构函数,以及一次拷贝
    构造函数的调用,增加了一次对a的默认构造函数的调用)。

    注:Lippman在《深度探索C++》书中指出NRV的开启与关闭取决于是否有显式定义一个拷贝构造函数,我实在想不出有什么理由必须要有显示拷贝构造函数才能开启NRV优化,于是在vs2010中进行了测试,测试结果表明,在release版本中,不论是否定义了一个显式拷贝构造函数,NRV都会开启。由此可见vs2010并不以是否有一个显式拷贝构造函数来决定NRV优化的开启与否。但同时,立足于这一点,可以得出Lippman所说的以是否有一个显式定义的拷贝构造函数来决定是否开启NRV优化,应该指的是他自己领导实现的 cfront 编译器,而非泛指所有编译器。那么 cfront 又为什么要以是否定义有显示的拷贝构造函数来决定是否开启NRV优化呢?我猜测,他大概这样以为,当显式定义有拷贝构造函数的时候一般代表着要进行深拷贝,也就是说此时的拷贝构造函数将费时较长,在这样的情况下NRV优化才会有明显的效果。反之,不开启NRV优化也不是什么大的效率损失。

    另外,有一点要注意的是,NRV优化,有可能带来程序员并不想要的结果,最明显的一个就是——当你的类依赖于构造函数或拷贝构造函数,甚至析构函数的调用次数的时候,想想那会发生什么。由此可见、Lippman 的 cfront 对NRV优化抱有更谨慎的态度,而 MS 显然是更大胆。

    6、成员初始化队列(Member Initialization List)

    初始化列表的初始化顺序是按声明顺序进行的,如下

    对于初始化队列,我相信厘清一个概念是非常重要的:在构造函数中对于对象
    成员的初始化发生在初始化队列中——或者我们可以把初始化队列直接看做是对
    成员的定义,而构造函数体中进行的则是赋值操作。所以不难理解有四种情况
    必须用到初始化列表:

    • 有const成员
    • 有引用类型成员
    • 成员对象没有默认构造函数
    • 基类对象没有默认构造函数

    前两者因为要求定义时初始化,所以必须明确的在初始化队列中给它们提供初
    值。后两者因为不提供默认构造函数,所有必须显示的调用它们的带参构造函
    数来定义即初始化它们。

    显而易见的是当类中含有对象成员或者继承自基类的时候,在初始化队列中初
    始化成员对象和基类子对象会在效率上得到提升——省去了一些赋值操作嘛。

    最后,一个关于初始化队列众所周知的陷阱,初始化队列的顺序,请参考《C++
    primer》或者《深度探索C++对象模型》。

    class X{
        
        int i;
        int j;
        public:
        X(int val):j(val),i(j) {}
        
    }
    int main()
    {
        X x(1);
        cout << x.i;
    }
    //会输出 0 可能是默认初始化了,类中未初始化的int值会为0值,测试过了,指针,浮点数不会,真的很奇怪
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    这段程序是要将j的初始值设置为val,再将i设置为j,但是由于声明顺序的原因,initialzation list中的 i(j) 其实要比 j(val) 更早执行的,所以这是会导致一定的错误出现。

    class X
    {
    public:
        int j;
    	int i;
    public:
        X(int val)
            : j(val), i(j)
        {
        }
    };
    int main()
    {
        X x(1);
        cout << x.i;
    }
    //更改声明顺序后,才会输出所预期的1
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    下面是回一个比较喜欢的解决方式:

    class X{
        
        int i;
        int j;
        public:
        X(int val):j(val) {
            i = j;
        }
        
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    initialzation list的执行先于 用户自定义的函数体,因此这样写就有效解决了上述问题。

    总的来说:编辑器会对initialzation list一一处理并可能重新排列,以反映出members的声明顺序,它会安插一些代码到 constructor 体内,并置于任何 explicit user code之前。

  • 相关阅读:
    360度无死角刨析C++STL中list的使用和实现,list与vector的对比
    KingbaseESV8R6垃圾回收受到参数old_snapshot_threshold的影响
    Html删除线
    NOIP 2007 普及组初赛试题 第21题
    JS 中的正则
    go开发环境安装配置(vscode)
    30.2.2 使用GRANT语句创建用户
    本地k8s部署kubesphere及踩坑记录
    【D3.js】2.2-给 Circle 元素添加属性
    半年报信号!良品铺子的稳健增长与长期势能
  • 原文地址:https://blog.csdn.net/q2453303961/article/details/125461442