首页 C++杂记 - 2
文章
取消

C++杂记 - 2

C++杂记 - 2

关于delete this

能否在类的析构函数中调用delete this?

不能

实验告诉我们,会导致堆栈溢出。原因很简单,delete的本质是“为将被释放的内存调用一个或多个析构函数,然后,释放内存显然,delete this会去调用本对象的析构函数,而析构函数中又调用delete this,形成无限递归,造成堆栈溢出,系统崩溃。

能否在类的其它函数中调用delete this

可以,但不可以涉及到任何和this指针(成员变量,成员函数,虚函数)相关的东西。一旦涉及到this指针,如操作数据成员,调用虚函数等,就会出现不可预期的问题。

首先,让我们明确几点概念

  • 在类对象的内存空间中,只有数据成员和虚表指针,并不包含代码内容。类的成员函数单独放在代码段中
  • 在调用成员函数时,隐含传递一个this指针,让成员函数知道当前是哪个对象在调用它。当 调用delete this时,类对象的内存空间被释放。在delete this之后进行的其他任何函数调用,只要不涉及到this指针的内容,都能够正常运行。一旦涉及到this指针,如操作数据成员,调用虚函数等,就会出现不可预期的问题。

  • delete this释放了类对象的内存空间,但是内存空间却并不是马上被回收到系统中,导致这段内存空间暂时并没有被系统收回。此时这段内存是可以访问的。这时访问数据成员可能是随机数,访问虚表发生指针无效的概率很高,系统崩溃。

什么时候可以?

this指向的实例必须是new出来的,不能是new[] /placement new出来的,也不能是栈上的,也不是全局变量。只能是简单的new出来的。

调用了delete this 的成员函数(下面称为当事函数)返回后,这个实例不可以调用其他任何成员函数(这好理解,涉及到this指针,因为实例不存在了)。这个成员函数应当成为这个实例访问的最后一个函数。

调用了delete this 的成员函数后(从delete this这一行之后开始),这个实例不可以调用任何成员函数和成员变量(这好理解,理由同2)

调用了delete this之后,不得以任何形式提及this,包括比较、打印、类型转换等(因为this已经不存在了)。

换言之,为什么一个指向类成员的指针被delete并置空后依旧可以调用不访问类成员数据的函数?或者是为什么一个类类型的空指针可以访问类内非虚且不访问成员变量的函数?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class myclass{
    public:
        void print(){
            cout <<"myclass" << endl;
        }

        int vala = 10;//类成员没有被print函数访问。

};
int main(){
    myclass* ptr = new myclass();
    ptr->print(); //输出myclass
    delete ptr;
    ptr = nullptr;
    ptr->print(); //输出myclass
    return 0;
}

我们看到delete后依旧可以调用没有访问类成员的函数。另一个前提是该函数不是虚函数。因为虚函数的调用需要依靠对象的虚函数指针找虚函数表。调用delete资源释放后置空导致和this有关联了。所以报错。

非虚的类成员函数在编译的时候就已经解决了访问问题。也就是编译成带作用域的全局函数。并添加this形参。也就是访问非虚而且不访问类成员(变量)的函数是不涉及到this指针的。虽然指针必须要被传入,但是传入参数并不关心当前指针是否有效。只要没有解引用this指针即可。

我们再思考一下。如果上面的例子换成C写是什么样子的?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct myclass_inC{
    int vala = 10;
};

void print(myclass_inC* self){
    cout <<"myclass" << endl;
}


int main(){
    struct myclass_inC *myclass_c = NULL;
    print(myclass_c); //输出my_class
    return 0;
}

在深度探索C++对象模型的笔记4.1我们提到:

因为编译器会把每所有的非虚函数(此处不确定是否正确。有的笔记里写的是普通函数,普通成员函数和静态成员函数。书里此处没有提及静态成员函数。)进行处理:

  1. 首先是把对象调用的方式改为值调用。也就是在形参的第一个位置放一个this指针(指向对象的指针)做为入参

  2. 其次是把函数内对于非静态数据成员的直接操作变成用this指针的操作。

  3. 然后是名称重新编译成独特名称(这一步的目的是实现重载。注意,C++重载是依靠名称和参数,C只有名称)
  4. 最后是实施NRV优化(如果有)

类成员函数在处理后可以理解为变成了带作用域的全局函数。虚函数也只不过是多了查表的步骤。

我们上面用C写的例子和处理后的非虚类成员函数差不多。此时我们可以看到。尽管我们的类指针是空的。但是它依旧符合函数入参类型。而且我们在函数内并没有通过这个指针访问任何数据成员。所以并不会报错。单纯传递空指针是可以的。所以是可以调用这样的成员函数的。

什么时候需要虚析构函数

是多态基类的时候。多态一定有虚函数。(effective C++ 第七条)

多态实现:通过指针+向上转换(子类转父类)(父类指针指向子类对象) + 虚函数 = 动态绑定 、虚机制

为什么如果析构函数不是虚函数的时候,仅仅执行父类析构函数?

很多人可能会有疑问,问我们知道构造顺序是先父类构造再子类构造,析构顺序是先子类析构再父类析构(仅多态情况)。那么为什么我父类析构不是虚析构的时候仅执行父类析构?难道不应该是仅执行子类析构吗?

因为忘了一点,多态调用必须是虚函数才可以,仅通过父类指针指向子类对象是不会触发虚机制(动态绑定(多态))的。

虽然父类指针可以指向子类,但是其访问范围还是仅仅局限于父类本身有的数据,那些子类的数据,父类指针是无法访问的。

类继承是静态绑定,包含虚函数的情况才是动态绑定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class A{
    public:
    A(){}

};

class B: public A{
    public:
    B(){}
    int bval = 3;
};

class C :public B{
    public:
    C(){}
    int cval = 4;
};

int main(){
    A* a = new C;
    cout << a->bval << endl; //错误。类A没有成员bval
}

当定义一个指向子类实例的父类指针的时候,内存中实例化了子类,由于子类继承了父类,因此内存中的子类里包含父类的所有成员。但由于申明的是父类指针,因此该指针不能够访问子类的成员,而只能访问父类的成员。然而在父类里可以声明纯虚函数和定义虚函数,使用父类指针访问虚函数或纯虚函数的时候,访问到的是子类里重写的函数。当然,对于虚函数,如果子类里没有对其重写的话,仍然访问到父类里定义的虚函数

父类指针指向子类对象,仅可访问子类的父类部分。因为不知道子类的内存布局,所以没法访问子类的部分。也就是说,父类指针指向子类对象会发生隐式转换。所以说它还是个父类类型。

多态的时候由于有RTTI所以可以在运行时获知子类类型。

我们提到过,指向虚表的指针_vfptr是子类的父类部分。也就是父类指针可以访问的到子类的指向子类自己的虚表的指针_vfptr。父类访问这个指针后即可访问子类的虚表。(反复强调虚表一个类只有一个)。访问到这个虚表后,就会查表。即访问对应的虚函数。如果子类有重写,那么这个对应的虚表中的函数会在编译期就从父类的覆盖为子类的。这样就可以执行子类的函数了。如果子类没写,那个虚表里面的函数就还是父类的虚函数。

所以,如果没有虚析构,则虚表里没有这一条。那么编译的时候发现不是虚函数,就会静态的将父类的析构函数编译过来。如果有了虚析构,则虚表里会有这一条。于是就会执行正常多态析构动作,先调用子类析构,再调用父类析构。也就是根据指向的对象找到对象的虚表指针,找到对象的虚表再调用对应的虚函数。实现多态。

运算符的重载,参数的数量怎么确定?在类里面和不在类里面?不在类里面声明怎么访问类里面的属性(如果是私有的呢?)

运算符重载可以继续被重载。符合函数重载规则即可。

类模板的运算符重载 在 模板笔记内

运算符重载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class multiple{
    public:
    int vala;
    int valb;
    multiple(){}
    multiple(int x, int y):vala(x), valb(y){}
    const multiple operator*(const multiple& rhs) const{
        multiple temp(this->vala * rhs.vala, this->valb*rhs.valb);
        return temp;
    }
    注意 乘法我们说是有两个参数即a*b 但是这里现在是成员函数,第一个参数默认是调用对象 this 所以形参只有一个rhs
    但是我们可以写为非成员函数,这样形参就会有rhslhs
    非成员函数在进行隐式类型转换的时候可以实现交换律。参考effectiveC++ 条款24
    void getval(){
        cout << this->vala<< ", " << this->valb << endl;
    }

};

const multiple operator*(const multiple& lhs, const multiple& rhs){
    multiple temp(lhs.vala * rhs.vala, lhs.valb*rhs.valb);
    return temp;
}

//类外可以这么写。注意const不可修饰非成员函数!!
int main()
{
    multiple t1(5,5);
    multiple t2(6,4);
    multiple t3 = t1 * t2;
    t3.getval();
这里t3 = t1 * t2本质上是t3 = t1.operator*(t2)
t1thist2rhst1.operator*(t2)对应函数返回值
}

注意 这里重载第三个const的目的是让这个函数成为const函数。目的是可以被const对象调用 这里的例子就是我们避免意外赋值,使重载返回值也是const 因为如果不设置const的话 像是

(t1 * t2) = t3;

这种就不会报错。因为t1*t2被当成了一个multiple类型的变量。可以给它赋值。因为它返回一个multiple对象。但是这违背了我们的意愿。所以我们加const来模拟常量确保t1*t2不会被赋值 如果我们这个重载函数不设置const属性(也就是第三个const)的话,我们这样就无法链式调用了。因为我们现在的t1*t2返回的是一个const multiple对象。const对象无法调用非const函数。所以我们的函数必须要const

  • 当运算符重载为类的成员函数时,函数的参数个数比原来的操作数要少一个(后置单目运算符除外),这是因为成员函数用this指针隐式地访问了类的一个对象,它充当了运算符函数最左边的操作数。因此:

    • 双目运算符重载为类的成员函数时,函数只显式说明一个参数,该形参是运算符的右操作数。

    • 前置单目运算符重载为类的成员函数时,不需要显式说明参数即函数没有形参

    • 后置单目运算符重载为类的成员函数时,函数要带有一个整型形参。

    • 调用成员函数运算符的格式如下:

      1
      2
      3
      
      <对象名>.operator <运算符>(<参数>)
      它等价于
      <对象名><运算符><参数>                
      
  • 当运算符重载为类的友元函数时,由于没有隐含的this指针,因此操作数的个数没有变化,所有的操作数都必须通过函数的形参进行传递,函数的参数与操作数自左至右一一对应。调用友元函数运算符的格式如下:

    1
    2
    3
    
    operator<运算符>(<参数1>,<参数2>)
    它等价于
    <参数1><运算符><参数2>
    
    • 友元函数不属于任何类,但是可以当做类成员函数使用,即访问私有部分。友元函数必须在类内声明。但是可以在类内或类外定义
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    
    class multiple{
        public:
        multiple(){}
        multiple(int x, int y):vala(x), valb(y){}
        friend const multiple operator*(const multiple& lhs, const multiple& rhs); //类内声明
        void getval(){
            cout << this->vala<< ", " << this->valb << endl;
        }
        private:
        int vala;
        int valb;
      
    };
    const multiple operator*(const multiple& lhs, const multiple& rhs){ //类内或类外定义均可。
        multiple temp(lhs.vala * rhs.vala, lhs.valb*rhs.valb);
        return temp;
    }
    int main()
    {
        multiple t1(5,5);
        multiple t2(6,4);
        multiple t3 = t1 * t2;
        t3.getval();
    }
    
  • 在类外,因为不是成员函数所以没有this指针,所以必须要有全部参数。

  • 运算符重载写为友元的核心目的是解决两个不同的类的数据访问问题。

    • 比如AB想要做A+B的时候。因为A不能访问B的私有成员。所以可以把这个A.operator+(const B&)写进B并且声明为friend

注意,类外的运算符重载(此处指的是类的运算符而不是函数调用运算符)的时候,不能单独写一个类。要么在原来的类里面,要么是全局函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class myclass{
    public:
    int val1;
    int val2;
    myclass(){}
    myclass(int x, int y):val1(x), val2(y){}
    bool operator()(const myclass& a, const myclass& b){
        cout <<"comp" <<endl;
        return a.val1 < b.val1;
    }
    
};

class mycomp{						//这么写是错的。这里的运算符重载被当做了mycomp的运算符重载
    public:
    bool operator<(const myclass& a, const myclass& b){
        cout <<"comp11" <<endl;
        return a.val1 < b.val1;
    }
};
bool operator<(const myclass& a, const myclass& b){
        cout <<"comp11" <<endl;
        return a.val1 < b.val1;
    }


int main()
{
    set<myclass> myset;					//如果重载的是小于号,则写在类内类外都不需要在模板处指定排序类型。
    set<myclass, myclass> myset1;		//如果重载的是括号也就是调用运算符,则无论写在类内类外都需要在模板处指定排序类型。
    myset.insert(myclass(5,10));
    myset.insert(myclass(1,10));
    myset.insert(myclass(10,10));
	return 0;
}

如果是指针容器呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class myclass{
    public:
    int val1;
    int val2;
    myclass(){}
    myclass(int x, int y):val1(x), val2(y){}
    bool operator()(const myclass& a, const myclass& b) const {
        cout <<"comp" <<endl;
        return a.val1 < b.val1;
    }
    bool operator()(const myclass* a, const myclass* b){
        cout <<"comp112" <<endl;
        return a->val1 < b->val1;
    }
    
};


class mycomp{
    public:
    bool operator()(const myclass* a, const myclass* b){		//写在类外
        cout <<"comp11" <<endl;
        return a->val1 < b->val1;
    }
};
int main()
{
    set<myclass*, myclass> myset;				//指针容器,必须使用重载函数调用运算符的方式,并且指定其类型。可以写在类外
    set<myclass*, mycomp> myset;				//也可以
    myset.insert(new myclass(5,10));
    myset.insert(new myclass(1,10));
    myset.insert(new myclass(10,10));
	return 0;
}

指针容器,必须使用重载函数调用运算符的方式,并且在容器模板处指定其(排序函数所在的)类型。可以写在类外。因为重载普通的运算符 如 <,要求传入的形参必须是类类型。但是指针不是类类型。所以无法写在类外。因为对象指针和对象不是一个东西。对象指针依旧是指针类型。所以他会去指针的类里面找重载,写在类内的重载无法被触发。

成员访问运算符 -> 重载

类成员访问运算符( -> )可以被重载,它被定义用于为一个类赋予”指针”行为。运算符 -> 必须是一个成员函数。如果使用了 -> 运算符,返回类型必须是指针或者是类的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class obj{
    public:
    int val;
    obj(int x):val(x){};

    void objfunc(){
        cout <<"obj" << endl;
    }
    void getval(){
        cout << val << endl;
    }
};
class objhelper{
    public:
    objhelper(){
        objptr = new obj(10); //构造函数初始化另一个类的对象
    }
    obj* operator->(){ //注意,返回的是obj类的指针。
        return objptr;
    }

    ~objhelper(){
        delete objptr; //析构函数释放对象。
    }
    obj* objptr; //类内持有一个另一个类的指针。

    
};
int main(){

    objhelper helper;
    helper->objfunc(); //输出obj
    helper->getval();  //输出10

    return 0;
}

从上面代码我们可以看到,重载类成员访问运算符多半是起到一个帮助作用,帮助我们访问另一个类。

重载成员访问运算符根据调用者的类型不同,有两条作用规则:

  1. 如果调用者是指针,则按照内置的箭头运算符去处理。表达式等价于(*调用者).member。首先解引用该指针,然后从所得的对象中获取指定的成员。如果调用者所指的类没有名为member的成员,则编译器报错。
  2. 如果调用者是一个定义了operator->() 的类对象,则调用者->member等价于调用者.operator->() ->member。其中,如果operator->()的返回结果是一个指针,则转第1步;如果返回结果仍然是一个对象,且该对象本身也重载了operator->(),则重复调用第2步,否则编译器报错。最终,过程要么结束在第一步,要么无限递归,要么报错。 https://blog.csdn.net/friendbkf/article/details/45949661

QQ截图20230311010451

——来自Modern C++ Design 7.3 P.160

解引用运算符*重载

*是一个一元操作符,作用于指针,获取指针所指单元的内容。当某个类中对*操作符重载时,是将该类对象当做一个指针看待,用*操作符提取指针所指向的内容。它一般有两个版本。一个是普通成员函数返回某个类中的指针的解引用的引用,也就是指针指向的对象的引用。另一个版本是常量成员函数返回const引用。(再次强调返回值不是函数签名。但是修饰成员函数的const是函数签名)

  • 为什么要返回引用?如果不返回引用则无法给解引用的对象赋值。比如*ptr = 200
    • 不返回引用的话,解引用返回的是一个临时对象,是右值。无法给右值赋值。
1
2
3
4
5
6
7
8
//在上面的代码中添加:
obj& operator*(){ //返回的是引用!!
    return *objptr; //返回类内指针的解引用
}

const obj& operator*() const{
    return *objptr;
}

取址运算符&重载

罕有使用。参考模板笔记的std::address_of部分和more effective c++的条款30。

解答:

  1. 在类内,如果是双目运算符就少一个。因为左手部分是this。隐式传递。如果是类外,就正常两个。
  2. 不在类里面声明,可以正常访问类公有部分。如果需要访问私有部分就需要使用友元函数。

关于为什么赋值运算符必须有返回值并且一般都返回引用

比如在移动赋值或者拷贝赋值中返回的都是引用。

原因主要是性能和为了链式调用。

  • 链式调用:
1
2
3
a = b = c;
//就是先执行b=c然后执行a = b。等价于
a.operator=(b.operator=(c))

此时若不返回左值的引用,将会生成临时对象,

因为不能修改标准库,所以重载流插入«和流提取»的时候如果在类内则必须用友元的方式。要么就是全局

为什么有时候运算符重载要在类外也写一份?

我们来看下面这个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class myInt{
    public:
    int val1;
    myInt(){}
    myInt(int x):val1(x){} //这里没有explicit
    
    const myInt operator+(const myInt& rhs){
        return myInt(this->val1 + rhs.val1);
    }
    
};

const myInt operator+(const myInt& lhs, const myInt& rhs){ //这里类外要有一份
    return myInt(lhs.val1 + rhs.val1);
}


int main()
{
    myInt a(10);
    myInt b(20);

    myInt c = a + b;

    myInt d = a + 10; //这个等于 myInt d = a + myInt(10); 因为有转换构造所以可以。
    
    myInt e = 10 + a; //这等于 myInt e = myInt(10) + a; 如果不写类外,这个不行。
    cout << c.val1 << endl; 
    cout << d.val1 << endl;
	return 0;
}
  • 我们这里有隐式类型转换。因为有单参的转换构造函数。所以如果构造函数用explicit修饰了就不行了,就要显式调用构造函数生成临时对象了。
  • 其次,如果不写类外实现,如10+a这样的就不能调用。因为a+10a.operator+(10),但是没有10.operator+(a)。所以这时候就需要有一个全局的接受两个参数的operator+。这两个可以并存。所以现在是operator+(10, a)。是可以的。
  • 所以一般来说,当前面的调用者是基本类型的时候,就需要写一个全局的操作符重载。

QQ截图20230106181815

千万不要重载 ||, &&,

因为他们的表现方式和内建版本不同。

  • 布尔逻辑运算符的重载版本无法实现短路求值,并且不会令左侧操作数的求值按照顺序早于右侧操作数。
  • 逗号运算符的重载版本不会令左侧操作数的求值按照顺序早于右侧操作数。

关于逗号运算符这里有一个非常特殊的点。在文档中提到了:

因为此运算符可能被重载,所以泛型库都用 a,void(),b 这种表达式取代 a,b

在stackoverflow也有同样的疑惑,为什么要这么做?

我们拆开看。在a有自己的逗号运算符的时候,a,void()会变成a.operator,(void())。但是因为void()表达式会返回一个void类型, 但是void不是有效类型(不完整类型),所以a无论是任何类型都不可能有一个自己的逗号运算符重载的操作数类型是void。同时重载operator,必须提供一个参数(右操作数)。 所以这里一定会用到内置的版本。在运算完后,结果会变成void,因为第一个表达式的运算结果在求值后会被丢弃,只会保留第二个的结果。现在是void,b了。左侧void必然只会用内置类型的逗号表达式。所以这样做是有效的。

剩下的请参照杂记4的序列点部分和more effective c++ 条款7

运算符重载的语义,个人感悟

运算符重载就是函数调用,一定要思考语义。

首先关于函数内部要做什么,要不要修改左侧操作数?

用加法举例子,如果你想要达到执行a+500能直接把a的数也改变,那么可以直接在加法重载内改变a的值。如果你不允许链式调用,则没必要有返回值。这完全合法。

包括拷贝赋值运算符也不是强制返回T&的。如果你不想链式调用,完全可以写成这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct myclass{
    int val = 0;
    myclass(int x):val(x){};
    myclass operator+(const myclass& rhs){
            return myclass(val + rhs.val);
    }
    void operator=(const myclass& rhs){
        val = rhs.val;
        cout <<"copy assignment" << endl;
    }
};
int main(){
    myclass obj(200);
    myclass obj2(300);
    cout << obj2.val << endl;
    obj2 = obj;
    cout << obj2.val << endl;
}

a.operator=(b)压根不是返回值作用于a。是先修改了a然后同时返回自己而已。目的就是链式调用。所以a = b这个表达式,返回值压根就没用上。

所以核心就是要思考某个特定的运算符重载是否符合直观性的语义。也就是一般性的使用方式。

可以参考这个:https://stackoverflow.com/questions/4421706/what-are-the-basic-rules-and-idioms-for-operator-overloading

关于mutable

很多人多mutable有错误认知。不仅仅是因为用得少,而且是有模糊概念。

mutable是修饰成员变量所用。他的作用是让这个成员变量在任何时候都可以被修改,无论是:

  1. 做为参数传入形参带有const的函数
  2. 做为参数传入被const修饰的(成员)函数
  3. 在被const修饰的(成员)函数中
  4. const修饰的变量中

首先举反例:

1
2
mutable int a; //这是啥?
const mutable int a; //这又是啥?

下面,正式开始举例:

1
2
3
4
5
6
7
8
9
10
class multiple{
    public:
    multiple(){}
    multiple(int x, int y):vala(x), valb(y){}
    
    
    mutable int vala;
    int valb;

};

我们拿到了类的定义。我们开始举例

1
2
mutable const int vala; //不可以 这啥玩意
const mutable int vala; //不可以 这啥玩意

为了简便起见,我们只举成员函数的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class multiple{
    public:
    multiple(){}
    multiple(int x, int y):vala(x), valb(y){}
    //------------------------------------- 被const修饰的成员函数
    void test1(multiple& x) const{
        vala = x.vala;
        x.vala = vala;
    }
    //-------------------------------------被const修饰的函数形参
    void test2(const multiple& x){
        vala = x.vala;
        x.vala = vala;
    }
    //-------------------------------------被const修饰的成员函数 和 被const修饰的函数形参
    void test3(const multiple& x) const{
        vala = x.vala;
        x.vala = vala;
    }
    //-------------------------------------
    mutable int vala;
    int valb;
};

以上三个函数代表了三个例子。我们发现被无论是在被const修饰的成员函数中 还是 被当成参数传入被const修饰的函数形参中,我们都可以修改mutable修饰的变量。

还有第四个例子

1
2
3
const multiple t1(3,3);
t1.valb = 3; //不可以
t1.vala = 3; //可以 因为被mutable修饰

四种cast显式(强制)类型转换表达式(运算符/关键字/特殊运算符),都不改变对象本身。

  • 四种cast强制类型转换是表达式(特殊运算符)。他只是长得像类模板但是不是。
    • 这类运算符还包括如new, delete, sizeof, typeid,noexcept等。
  • 四种cast强制类型转换对转换对象没有任何影响。他只是生成一个你想转换的类型的临时变量。
    • 无论何种类型转换都是创建对应类型的临时”对象”。
    • 此处可能不严谨。如果转换的类型是引用,则不会创建“对象”因为引用不是对象。我们可以说cast总是会创建一个临时的“东西”
  • 强制类型转换并不改变原对象类型,只是通过原对象生成新的对象。
  • C风格类型转换是运算符。优先级是第三级。C++风格类型转换也是运算符。优先级是第二级。
  • 通过如构造函数的方式进行显式类型转换,我们称之为函数风格转换functional-style cast。
    • 比如新类型 (表达式)
    • C风格的不要搞混。C风格的是(新类型) 表达式
    • C风格和函数风格没什么差别 —- effective C++ 条款27
  • 函数指针有类型,所以可以进行类型转换

记住了,所有类型转换表达式后面的圆括号里面塞的也是表达式!!!

1
2
3
int* p = new int(20);
void* pp = static_cast<void*>(p); //p是表达式。
void* pp = static_cast<void*>(int*);//这是啥玩意?int*是类型,不是表达式

例子:

1
2
3
4
float ss = 10.12345;
cout << ss << endl; //10.12345
cout << static_cast<int>(ss) << endl; //10
cout << ss <<endl; //10.12345

由此可见,强制类型转换会生成一个转换类型的临时变量。并不改变原来的对象。

https://zh.cppreference.com/w/cpp/language/explicit_cast

强制类型转换可以进行引用类型的转换

其实就是通过原对象生成一个原对象的引用。这个操作合法。

例子:

1
2
3
4
5
6
float ss = 10.12345;
float& dd = static_cast<float&>(ss); //生成一个ss的引用。也就是通过ss生成一个新的float&类型的对象。
cout << dd <<endl; //10.1235
dd = 9.324;
cout << dd << endl; //9.324
cout << ss << endl; //9.324

reinterpret_cast简单介绍

需要记住的有两点:

  • reinterpret_cast 表达式不会编译成任何 CPU 指令(除非在整数和指针间转换,或在指针表示依赖它的类型的不明架构上)。它纯粹是一个编译时指令,指示编译器将 表达式 视为如同具有 新类型 类型一样处理。
  • reinterpret_cast不能去除CV。去除CV需要使用const_cast

关于static_castconst_cast之间的区别

static_cast

1
static_cast <type-id> (expression)

将expression转换为type-id类型,主要用于非多态类型之间的转换,不提供运行时的检查来确保转换的安全性。主要在以下几种场合中使用:

  1. 用于类层次结构中,父类和子类之间指针和引用的转换;
    • 当进行上行转换,也就是把子类的指针或引用转换成父类表示,这种转换是安全的;
    • 当进行下行转换,也就是把父类的指针或引用转换成子类表示,这种转换是不安全的,也需要程序员来保证;
  2. 用于基本数据类型之间的转换,如把int转换成char,把int转换成enum等等,这种转换的安全性需要程序员来保证;
    • static_cast可以转换对象。
      • 转换对象的前提是:
        • 基础类型之间:提到过比如int换成char
        • 有继承关系之间的对象。且由于是对象,只能进行上行转换。也就是子类对象转为父类对象。父类对象转为子类对象不可能的。因为只能往小切,不能往大扩。多出来的内存空间不知道用来作什么。
        • 可被转换(如拥有转换(构造)函数)的对象
  3. 把void指针转换成目标类型的指针,是及其不安全的;
  4. 将一个左值转换为右值引用,这是允许的。(std::move的实现)。对于操作右值引用的代码来说,将一个右值引用绑定到一个左值的特性允许它们截断左值。有时候这种截断是安全的
  • static_cast不能移除掉表达式的const、volatile和__unaligned属性。但是可以添加。

dynamic_cast

1
dynamic_cast <type-id> (expression)

将expression转换为type-id类型

  • dynamic_cast转换仅适用于指针或引用。

  • type-id必须是类的指针、类的引用或者是void *;如果type-id是指针类型,那么expression也必须是一个指针;如果type-id是一个引用,那么expression也必须是一个引用。

  • 必须满足多态条件(有虚函数,因为依赖RTTI)

  • 不能移除CV限定。

dynamic_cast主要用于类层次间的上行转换和下行转换,还可以用于类之间的交叉转换。在类层次间进行上行转换时,dynamic_cast和static_cast的效果是一样的;在进行下行转换时,dynamic_cast具有类型检查的功能,比static_cast更安全。在多态类型之间的转换主要使用dynamic_cast,因为类型提供了运行时信息。就像我在虚表那一章节说的。虚表头还有一块东西就是RTTI,表明了当前类的类型信息。

一般来说,dynamic_cast 用于下行转换的时候,只能用于有虚函数表的类。因为dynamic_cast依赖于RTTI的type_info,然而这个信息储存在虚函数表的头部。

这个dynamic_cast是怎么用的呢?我们父类指针指向子类对象的时候,限制了指针仅可以读取子类对象的父类部分。我们进行dynamic_cast的时候,会使用RTTI信息。这个信息是储存在虚函数表的头部。也就是编译器找到了虚函数表指针(子类对象的虚函数表指针一定指向自己的虚函数表,所以RTTI信息类型也是子类),然后问RTTI:”这人想要让我转换成子类,也就是多读点儿子类的东西,你帮我看看我指向的对象到底是不是子类,多读的那部分被正确初始化了没有?合法不合法?“。RTTI看了一眼回答:”哦,你指向的部分实际类型确实是一个子类的类型,因为他new的时候new的是一个子类对象。我检查了,都初始化了,也合法,那你就多读点儿吧“。这就是dynamic_cast和RTTI的联系。

有一个实际问题,当我们需要判断该指针实际指向的类型的时候,假如我们加了一堆ifelse然后用dynamic cast判断,这非常慢,尤其是继承非常复杂的时候,效率非常低。需要注意。所以很多引擎的RTTI是自己的。

dynamic_cast 在传入引用和指针的时候不一样。

  • 使用指针的时候,如果可以转换则传回真正的地址。
  • 如果不可以则会返回0(nullptr)表示转换失败

但是使用引用的时候却不可以这样。首先,引用不能像指针那样设置为0。设置为0代表着有一个临时对象产生出来,然后这个临时对象初始值为0,然后这个引用成为了这个临时对象的别名。(右值相关,对吧。)

  • 所以使用引用的时候,如果可以转换则会把对象引用至正确的子类。
  • 但是如果失败,由于引用不可以传0,则会抛出一个bad_cast_exception

dynamic_cast 的向上转换是编译时还是运行时

向上转换是编译时。

  • 但是在某些stack overflow 的回答中侧面展示了另一种可能。也就是这依赖于实现
    • https://stackoverflow.com/questions/7687041/dynamic-cast-with-rtti-disabled
    • RTTI是可以关闭的,但是关闭之后dynamic_cast会对一些转换标记为非法。这些标记通常会被用于那些向下转换。
    • 也就是说使用dynamic_cast向上转换的时候,转换关系在编译时是已知的。那么这个时候dynamic_cast不会依赖于RTTI,也就是会被提前到编译时。
  • include社群的回答是这样:
    • 在使用dynamic_cast进行非向下转换(也就是向上转换和同类型转换的时候),不涉及RTTI,也就是不涉及运行时。编译时就可以做到。
    • 进行向下转换的时候,必然RTTI也就是必然运行时。
    • 因为upcast是自动的,不需要任何的显式cast。
  • 来自CPP开发者
    • dynamic_cast最终会调用libstdc++中的__dynamic_cast函数。
    • 如果是向上转换,编译时即可完成。
    • 如果是向下转换,需要运行时才可以。

关于更多dynamic_cast,虚继承,多重虚继承和dynamic_cast的设计,看这里

关于const_cast

给一般读者:

  • 常量指针(指向常量的指针)被转化成非常量的指针(或逆向),并且仍然指向原来的对象;
  • 常量引用被转换成非常量的引用(或逆向),并且仍然指向原来的对象;
  • 不改变原指针/引用的常量状态。
  • 不可进行类型更改,只能进行CV操作。

我们来看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
using namespace std;
class multiple{
    public:
    multiple():valc(0){}
    multiple(int x, int y, int z):vala(x), valb(y), valc(z){}
    int vala;
    int valb;
    const int valc;

};

int main()
{
    multiple p(1,3,5);
    const multiple* const_ptr = &p; 					//指向常量的指针
    //---------------------------------------------------------------------
    const_ptr = const_cast<multiple*> (const_ptr);		//进行const cast
    const_ptr->vala = 100;								//不可以, 必须要赋值给新的指针。
    const_cast<multiple*> (const_ptr)->vala = 100; 		//可以

    //---------------------------------------------------------------------
    multiple* ptr = const_cast<multiple*> (const_ptr);	//赋值给新指针
    ptr->vala = 10;										//可以
    ptr->valc = 100;									//不可以。因为valc是常量。
}

具体使用场景

1. 一个函数的形参不是const指针/引用,并且编程者事先已经知道在这个函数中不会对参数进行修改,但需要传递的实参却是已经定义好的const对象。为了成功调用这个函数,就需要利用到const_cast在实参传递前对其进行处理,从而使函数能够成功接收这个实参

代码:

1
2
3
4
5
6
7
8
9
void Function(int &val){
    cout<<val<<endl;
}
int main(){
    const int value=21;
    Function(value);					//不可以
    Function(const_cast<int&>(value));	//转换为常量引用,可以
    return 0;
}

2. 如果我们定义了一个非const的变量,却使用了一个指向const对象的指针来指向它,而在程序的某处希望改变这个变量时发现只有指针可用,此时就可以const_cast进行处理

1
2
3
4
5
6
7
8
int main(){
    int value=26;
    const int* const_ptr=&value;
    *const_ptr=3;							//不可以
    int* ptr=const_cast<int*>(const_ptr);	//转换为非常量指针。可以
    *ptr=3;
    return 0;
}
  • 使用const_cast去除const限定的目的绝对不是为了修改它的内容,只是出于无奈

给能思考的读者。因为这里我还没有完全弄明白,可能存在错误

原因是全局const在静态区。不能间接修改。因为这个内存段所属的页面的权限是只读的,硬写一定会段错误。而局部const在栈上。这个内存段所属的页面是可读也可写的,所以可以间接修改。但是修改任何标记为const的变量都是未定义行为。

我们直接看代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class multiple{
    public:
    multiple():valc(0){}
    multiple(int x, int y, int z):vala(x), valb(y), valc(z){}
    int vala;
    int valb;
    const int valc;

};
int main()
{
    const multiple np(2,4,6);					//常量对象
    multiple ucnp = const_cast<multiple&>(np);	//这里下面会解释
    ucnp.vala = 10;
    //---------------------------------------------------------
    cout << ucnp.vala<< endl;						//输出10
    cout << ucnp.valb<< endl;						//输出4	
    cout << ucnp.valc<< endl;						//输出6
    //---------------------------------------------------------
    cout << np.vala<< endl;						//输出2
    cout << np.valb<< endl;						//输出4	
    cout << np.valc<< endl;						//输出6
}


这里面我们进行const_cast的时候返回值并没有使用引用来接受。这是可以的。这里会产生一个临时变量做为中间值传给了结果值

有没有注意到问题,我们的np不是引用,是个对象。可能无法直观感受,我们接着看

这句话不知道对不对。来自:https://www.cnblogs.com/dracohan/p/3417842.html

下面有更狠的。

我们把ucnp换成引用。看看结果如何

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main()
{
    const multiple np(2,4,6);					//常量对象
    multiple& ucnp = const_cast<multiple&>(np);	//注意这里
    ucnp.vala = 10;
    //---------------------------------------------------------
    cout << ucnp.vala<< endl;						//输出10
    cout << ucnp.valb<< endl;						//输出4	
    cout << ucnp.valc<< endl;						//输出6
    //---------------------------------------------------------
    cout << np.vala<< endl;						//输出10
    cout << np.valb<< endl;						//输出4	
    cout << np.valc<< endl;						//输出6
}

好像没什么问题啊?确实是这样的嘛。常量引用转换成非常量引用,有啥问题?

你再仔细看一眼。np是什么?是对象啊。这是个常量对象啊,我们修改了常量对象啊!

我们可以来一个更明显的例子:

1
2
3
4
5
6
7
8
9
int main{
    
    multiple p(1,3,5);
    cout << p.valc << endl;					//输出5
    int& wtf = const_cast<int&>(p.valc);	//修改常量。
    wtf = 1000;
    cout << p.valc << endl;					//输出1000
    return 0;
}

我们的valc是一个const int。但是这里我们修改了一个对象里的常量。

以上操作,编译器没有任何警告。但是这应该是一个未定义行为。需要避免修改任何已经声明为常量的对象。const_cast只能正确转换顶层(引用,指针)的常量性质,但是无法正确转换底层(引用的本体,指针指向的对象)的常量性质。强行转换会导致未定义行为。

  • const_cast的一些错误使用可能不会让编译器报错。但是它不会做你想要让它做的,

const_cast的正确使用方式

假设有如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class my_array{
    public:
    	const char& operator[](size_t offset) const{
            return buffer[offset]
        }
    	char& operator[](size_t offset){
            return const_cast<char&>(const_cast<const my_array&>(*this)[offset])
        }
    private:
    	char buffer[10];
};
int main(){
    const my_array c_arr;
    my_array arr;
    return 0;
}
  • 这种情况下,与其写两个函数,不如让非const的调用const。具体过程如下。
  • 如果是const对象调用,则正确匹配至const成员函数。
  • 如果是非const对象调用,则正确匹配至非const成员函数。在非const成员函数内,我们先把自己cast成const引用,必须是引用否则就新建对象了。然后这时候调用operator[]会匹配到const的成员函数。这时候返回的是const char&。然后再把这个带const的cast成char&。这样做是安全的,因为传入的数组本来就不是const的,可以修改。我们只是把自己cast成了const引用。但是本身还是非const

C风格cast的过程

QQ截图20230203142712

来自这里

explicit 和 隐式类型转换 和 转换构造函数

叽叽歪歪一大堆没意思。先看一个关键知识点

当一个构造函数只有一个参数,而且该参数又不是本类的const引用时(这里存在不同说法。一说是只要不是本类类型即可),这种构造函数称为转换构造函数,即转换构造函数为单参构造函数的一种,又是类型转换函数的一种。这里的只有一个参数可以拆分为,只有一个形参,或,形参中只有一个没有默认值。

  • 注意了。这里提到的是转换构造函数。下面提到的operator开头的叫用户定义转换函数。
  • c++11后单参的限制被放宽了。只要不被explicit修饰的构造函数都可以叫做转换构造函数。(也就是多个参数也可以)
  • 隐式声明的及用户定义的非显式复制构造函数与移动构造函数也是转换构造函数。

explicit只能限制住拷贝初始化,并不能限制直接初始化(杂记1拷贝初始化那段里面有原因)。我们下面提到了四种隐式转换的条件。

啥意思?

只有一个形参

1
2
3
4
5
6
7
8
9
10
11
class A
{
public:
    A(int i):a(i){} 		//单参构造函数
    int getValue()
    {
        return a;
    };
private:
    int a;
};
1
2
3
4
5
6
7
int main(){
    A a = 10;			//case1
    A a(10);			//case2 注意这是直接初始化,不会受到explicit限制。
    A a;				//case3
    a = 10;
    return 0;
}

case1中,我们没有临时对象。而是直接把10作为参数传递给类型转换构造函数。使用了拷贝初始化(先调用默认构造,再调用拷贝构造)这虽然使用了”=”,但是实际上我们在创建新对象。创建新对象的时候一律是拷贝构造而不是拷贝赋值。因为对象还不存在

case2中,我们没有临时对象,使用直接初始化调用默认构造。

case3中,我们有临时对象。编译器执行 a = 10;的时候相当于先使用 A(10);在栈中创建了一个临时对象。然后再调用对象a的拷贝赋值 a(10)a初始化(因为对象a已经存在)。然后临时对象销毁。这就是编译器做的隐式转换工作。

也就是说,如果我们使用了explicit,这里将会变成:

1
2
A a;
a = A(10); 	//显式调用构造函数 禁止隐式转换

多个形参但是只有一个没有默认值

1
2
3
4
5
6
7
8
9
10
11
12
class A
{
public:
    A(int i, int j = 5):a(i),b(j){} 		//单参构造函数
    int getValue()
    {
        return a;
    };
private:
    int a;
    int b;
};

原理和上面一样。但是有一个知识点:函数默认值假如第N个参数有,那么N后面的参数必须全都有。

发生隐式转换的条件

注意!四种情况会发生隐式转换:

  • 混合类型的算数运算表达式。
1
2
3
int a = 3;
double b = 4.5;
a + b; // a将会被自动转换为double类型,转换的结果和b进行加法操作
  • 不同类型的赋值操作。(这里包含拷贝初始化)
1
2
int a = true ; ( bool 类型被转换为 int 类型)
int * ptr = null;null被转换为 int *类型)
  • 函数参数传值
1
2
void func(double a);
func(1); // 1被隐式的转换为double类型1.0
  • 函数返回值。(此处例子)
1
2
3
4
5
double add( int a, int b)
{
    return a + b;
} //运算的结果会被隐式的转换为double类型返回

针对第四种我们举个例子。看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class tmp{
    public:
    string _text;
    tmp(string c): _text(c) {}			//单参构造,可以看做转换构造

};
class test{
    public:
    string _text;
    test(string a): _text(a){}		//单参构造,可以看做转换构造
    test(tmp s): _text(s._text){}  //单参构造,可以看做转换构造。此处入参是tmp类型,return一个test类型,可以看成tmp可以转为test
};
class twonumber{
    public:
    test ret(){
        test(tmp("cde"));
        return(tmp("abc")); //此处返回的对象是一个tmp,但是函数头的返回类型却是test。可以运行。这里使用了构造函数的隐式转换。使用了临时对象
        return(test(tmp("abc"))); //如果设置了explicit,则需要这样进行显式转换。
    }
    //!由于return语句内是隐式转换,所以对应的构造函数如果有explicit关键字则无法进行隐式转换。即便这里看起来像拷贝初始化。

};
int main(){
    twonumber ttt;
    test rrr = ttt.ret();
    cout << rrr._text << endl;
    return 0;
}

隐式类型转换都会创建临时对象

我们在上面看到了,为什么下面的类型转换是OK的?

1
2
3
4
test ret(){
        test(tmp("cde"));
        return(tmp("abc")); 
}

因为虽然我们要返回test对象,而且我们实际把一个tmp对象塞进去了,但是test类有一个使用tmp对象为形参的构造函数。编译器此时会调用test类的形参为tmp的构造函数,生成一个临时对象。然后再把这个test类型的临时对象return出去(赋值回去)。

这里比较特殊是做为函数返回值了。可能有NRV优化。但是正常的隐式转换就是这个样子,调用对应类型和入参的构造函数构造临时对象,再赋值。

显式类型转换无非就是手动调用了构造函数。

为什么引用传参不会发生隐式类型转换从而产生临时对象?

假设我们有一个函数

1
2
3
void changesomething(string& s){
    //改变一些东西
}

如果此时有隐式类型转换,则一定会有临时对象。那么我们修改的其实是那个临时对象。传入的那个字符串并没有被修改。所以这会引起错误。

所以c++禁止为非常量引用产生临时对象,而常量引用没所谓。以为常量引用保证了对象不会被修改。注意这个和拷贝没啥关系

说到底,我们啥时候想禁用呢?

  • 当类中同时存在“non-explicit-one-argument 构造函数和类型转换函数的时候。有可能产生二义性。所以需要对“non-explicit-one-argument构造函数前加explicit防止编译器隐式自动转换类型
  • explicit可以理解为,在拷贝初始化的时候,尝试去对等号右侧对象进行一个隐式转换以满足等号左侧的类型要求。但是explicit会让隐式转换序列中,忽略掉explicit标注的类型转换函数(转换构造函数或用户定义的类型转换函数)。所以他会找不到对应的转换方式所以约等于禁止隐式转换了。
  • 也就是说,如果加了explicit,使用拷贝初始化的时候我们就必须用显式类型转换。比如C风格cast或c++风格的cast或显式调用构造函数进行直接初始化。
    • 显式地调用构造函数进行直接初始化实际上是显式类型转换的一种。

再次重申,explicit只限制拷贝初始化,不限制直接初始化。而且对象类型为本类类型的时候不限制。因为如果是本类类型不涉及任何类型转换。

1
2
3
4
A a(5);		//可以。直接初始化
A a = 5;	//不可以,拷贝初始化。
A b(a);		//可以。对象类型为本类,不属于类型转换。不发生隐式转换。
A b = a;	//可以。对象类型为本类,不属于类型转换。不发生隐式转换。
  • 可以进行多种构造函数的搭配自由使用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class obj{
    public:
        obj(){};
        obj(int a):val1(a){};
        explicit obj(int a, int b):val1(a), val2(b){};
    int val1;
    int val2;
};

int main(){
    obj t1; //可以,有默认构造函数
    obj t2(10,20); //可以,explicit只能限制拷贝初始化。这里是直接初始化。
    obj t3 = (10,20);//不可以,禁止隐式类型转换。
    obj t4 = obj(10,20);//可以,显式类型转换。
    obj t5 = (20); //可以。因为这个构造函数没有被explicit修饰
    
    vector<obj> myvec{obj(10,20), (20)}; //可以这样搭配使用。

    return 0;
}

所以explicit卡在哪儿了?卡在隐式类型转换所需的构造等号右侧的临时对象了。

我们上面提到了如果其他对象的类型不是目标类型或从目标类型派生,那就需要找能转换的转换序列(包括转换构造函数和用户定义转换函数)。说人话就是我们要使用等号右面的这个东西,构建出一个等号左边的类型的临时对象。但是在这个临时对象的构造中(转换序列的查找中),忽略掉所有带explicit声明符的可用函数。这时候自然就防止了隐式类型转换。因为一般情况下,我们期望的是提供一系列参数,然后进行对象构造。按理说直接初始化提供的参数和拷贝初始化提供的参数是相同的。因为在拷贝初始化临时对象后,会调用拷贝构造或移动构造来对左侧的对象进行初始化。

但是,它有时候想的和你不太一样。比如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
explicit myobj(int x):val(new int(x)){ //explicit
    cout <<"const" << endl;
}
myobj(int x, int y = 20):val(new int(x)){ //带默认值
    cout <<"const with default value" << endl;
}
myobj b = 10; //可以

/*
const with default value
mv
dest
dest
*/
  • 这个时候为啥可以呢?因为尽管通过10来构造myobj临时对象的时候,第一个匹配的构造函数是explicit不能用,但是第二个可以用。所以此时依旧可以拷贝初始化。

对于类来说,直接在类型后面加上括号,就是调用构造函数

类型后面+名字再+括号只不过是给了个名字而已。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class p{
    public:
    int a;
    int b;
    p(){}
    p(int x, int y):a(x),b(y){}

};
int main(){
    p t1;
    t1.a(5); //这是啥东西?构造函数怎么能构造出来对象的变量而不是对象本身呢?怎么可以通过构造函数给变量赋值?
    t1.a = 5;//对了
    
	
}

类型转换函数 (用户定义转换函数)

我们提到过:当一个构造函数只有一个参数,而且该参数又不是本类的const引用时(这里存在不同说法。一说是只要不是本类类型即可),这种构造函数称为转换构造函数,即转换构造函数为单参构造函数的一种,又是类型转换函数的一种

  • 所以类型转函数还有一个单独的函数。也就是operator type()。他的作用是把一个类类型转换成另一个类类型。也叫类型转换函数或用户定义转换函数。注意和转换构造函数区别开。
  • 它的目的是让我们的这个类对象可以转换成满足目标类对象的构造函数的样子。
  • 注意,用户定义转换函数依旧可以被explicit修饰。具体作用和构造函数一致。
    • 如果用户定义转换函数被explicit修饰,则该用户定义转换函数不是用户定义转换,不被隐式类型转换考虑
    • 使用explicit修饰用户定义转换函数后,就需要使用如static_cast的形式显式触发类型转换。
  • 一般来说,转换构造函数的目的是把其他类转换为本类。用户定义转换函数的目的是把本类转换为其他类。

基础语法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class 源类{
    源类(目标类){
        //这种转换构造函数的目的是把目标类转为源类。也就是把其他类转为本类
    }
    operator 目标类(){
        //这种用户定义转换函数的目的是把源类转为目标类。也就是把本类转为其他类。
        目标类 obj;
        //根据需要进行从源类型到目标类型的转换
        
        return obj;
    }
}
class Type{
    
    operator OtherType(){
        
        OtherType obj;
        //....
        
        return obj;
    }
}

此处,我们的目的是把Type转换为OtherType

所以可以有这样的类型转换调用(隐式):

1
2
3
4
int main(){
    OtherType obj = Type();
    return 0;
}

来看进阶例子。此处是把other转换为test

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class test{
    public:

    int val_1;
    int val_2;
    test(int x = 4, int y = 5):val_1(x), val_2(y){}
};

class other{
    public:
    int val1;
    int val2;
    other(int x = 10, int y = 100):val1(x), val2(y){}
    operator test(){   //用来把other转换为test。
        test obj;
        obj.val_1 = val1;
        obj.val_2 = val2;
        return obj;
    }
};


int main(){
    other O;
    test T;
    cout << T.val_1 << endl;
    T = O;              //other转化为test
    T = (test)O;        //显式
    
    test obj1(other()); //不行,语法不对,会被当成函数。必须要用花括号
    test obj1(other{}); // OK 因为有类型转换。
    
    cout << T.val_1 << endl;
    return 0;
}

类型转换函数会为我们提供隐式类型转换。所以会产生临时对象。其实代码中就体现出来了。因为我们单独创建了一个对象。

注意这里我们test类没有写自己的拷贝构造。所以编译器默认生成了。当我们执行test obj1(other{});的时候,编译器先会想办法把other弄成满足test构造函数的要求的类型。这里我们test没有对应的构造函数,所以会开始查看other的转换函数。转换函数内部先生成test类对象(构造),然后拷贝出来(拷贝构造)然后再使用拷贝出来的test临时对象去调用拷贝构造去构造obj1(第二次拷贝构造)。[没有编译器优化的时候]

  • 用户定义转换函数内并不一定需要返回一个目标类对象。也可以是返回一个可以构造目标类对象的参数。(满足目标类的构造函数)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class middle{
    public:
    int val_a;
    int val_b;
    middle(int a, int b):val_a(a), val_b(b){}
};

class test{
    public:

    int val_1;
    int val_2;
    test(int x = 4, int y = 5):val_1(x), val_2(y){}
    test(const middle& a):val_1(a.val_a), val_2(a.val_b){}
};

class other{
    public:
    int val1;
    int val2;
    other(int x = 10, int y = 100):val1(x), val2(y){}
    // operator test(){   //不能有重载,会有二义性。
    //     test obj;
    //     obj.val_1 = val1;
    //     obj.val_2 = val2;
    //     return obj;
    // }
    operator test(){   //用来把other转换为test。
        middle obj(val1, val2); //因为test类可以接受一个middle对象来进行构造。所以这样也可以。
        return obj;
    }
};

int main(){
    test t_obj = other(1,2);
}

因为test类可以接受一个middle对象来进行构造。所以这样也可以。

当然了最好的写法是和返回语句写到一起。这里是为了看起来清晰。

  • 因为核心是让我们源类对象转换成满足目标类对象的构造函数的样子。所以甚至这样都是可以的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class test{
    public:

    int val_1;
    int val_2;
    test(int x = 4, int y = 5):val_1(x), val_2(y){}
};

class other{
    public:
    int val1;
    int val2;
    other(int x = 10, int y = 100):val1(x), val2(y){}
    operator test(){   //用来把other转换为test。
        return val1;
    }
};

int main(){
    test t_obj = other();
    cout << t_obj.val_1 << t_obj.val_2 << endl; //10 5

}

因为test类对象的构造函数都含有默认值。所以我甚至在转换函数内只返回了第一个参数的值。这个值随后会被当做第一个参数放入test的构造函数。所以最后输出的结果会是10,5。这个10来自other对象构造函数的默认值。这个5来自test对象构造函数的默认值。

  • 用户自定义的转换函数还可以是虚函数,但是只有从基类的引用或指针进行派发的时候才会调用子类实现的转换函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct D;
struct B {
    virtual operator D() = 0;
};
struct D : B
{
    operator D() override { 
        cout << "D" << endl;
        return D(); 
    }
};
 
int main()
{
    D obj;
    D obj2 = obj; // 不调用 D::operator D()
    B* br = new D;
    D obj3 = *br; // 通过多态调用 D::operator D() 
}
  • 用户定义转换函数不能是类的静态成员函数。

显式类型转换和隐式类型转换的定义

显式类型转换

隐式类型转换

## 显式类型转换定义

如果对于某个虚设变量 temp 存在良构声明 目标类型 temp(表达式 );,那么表达式 可以显式转换到目标类型

隐式类型转换定义

当且仅当 T2 能从表达式 e 复制初始化,即对于虚设的临时对象 t,声明 T2 t = e; 良构(能编译)时,称表达式 e 可隐式转换到 T2。注意这与直接初始化T2 t(e))不同,其中还会额外考虑显式构造函数和转换函数。

隐式转换序列

转换构造函数

上文explicit提到过

类型转换函数

就在上面。

对于需要进行隐式转换的上下文,编译器会生成一个隐式转换序列:

  1. 零个或一个由标准转换规则组成的标准转换序列,叫做初始标准转换序列
  2. 零个或一个由用户自定义的转换规则构成的用户定义转换序列
  3. 零个或一个由标准转换规则组成的标准转换序列,叫做第二标准转换序列

对于隐式转换发生在构造函数的参数上时,第二标准转换序列不存在。

初始标准转换序列:

初始标准转换序列很好理解,在调用用户自定义转换前先把值的类型处理好,比如加上cv限定符:

1
2
3
4
5
6
7
struct A {};
struct B {
    operator A() const;
};
 
const B b;
const A &a = b;

初始标准转换序列会把值先转换成适当的形式以供用户转换序列使用,在这里operator A() const希望传进来的this是const B*(this)类型的,而对b直接取地址只能得到B*,正好标准转换规则里有添加底层const的规则,所以适用。

如果值的类型正好,不需要任何预处理,那么初始标准转换序列不会做任何多余的操作。

如果第一步还不能转换出合适的类型,那么就会进入用户定义转换序列。

用户定义转换序列

  • 用户定义的转换函数在隐式转换的第二阶段被调用,第二阶段由零个或一个转换构造函数零个或一个用户定义转换函数构成。
  • 如果转换函数和转换构造函数都能用于进行某个用户定义转换
    • 如果类型是直接初始化(参考杂记1),那么只会调用转换构造函数
    • 如果是复制初始化或者引用绑定,那么转换构造函数和用户定义转换函数会根据重载决议确定使用谁(忽略掉explicit标注的转换函数)。另外如果转换函数不是const限定的,那么在两者都是可行函数时优先选择转换函数,比如operator A();这样的,否则会报错有歧义(GCC 10.2上测试显示有歧义的时候会选择转换构造函数,clang++11.0和标准描述一致)。这也是我们复习了几种初始化有什么区别的原因,因为类的构造形式不同结果也可能会不同。

选择好一个规则后就可以进入下一步了。

如果是在构造函数的参数上,那么隐式转换到此就结束了。除此之外我们需要进行第三步。

第二标准转换序列

这是针对用户转换序列处理后的值的类型做一些善后工作。之所以不允许在构造函数的参数上执行这一步是因为防止过度转换后和用户转换规则产生循环。

1
2
3
4
5
6
7
struct A
{
    operator int() const;
};
 
A a;
bool b = a;

在这里a只能转换成int,而为了偷懒我们直接把a隐式转换成bool,问题来了,初始标准转换序列把A*转换成了const A*(作为this,类方法的隐式参数),用户转换序列把const A*转换为了int,int和bool是完全不同的类型,怎么办呢?

这就用上第二标准转换序列了,这里是数值转换,int转成bool。

不过上面只是个例子,请不要这么写,因为在实际代码中会出现问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template <typename T>
struct SmartPointer {
    //...
    T *ptr = nullptr;
    operator bool() {
        return ptr != nullptr;
    }
 
    T& operator*() {
        return *ptr;
    }
};
 
auto ptr = get_smart_pointer();
if (ptr) {
    // ptr 是int*的包装,现在我们想取得ptr指向的值
    int value = ptr;
    // ...
}

上面的代码不会有任何编译错误,然而它将引发严重的运行时错误。

为什么呢?因为如注释所说我们想取得指针指向的值,然而我们忘记解引用了!实际上因为要转换成int,隐式转换序列里是这样的:

  1. 初始标准转换序列 —–> 当前类型已经满足调用用户转换序列的要求了,什么都不做
  2. 用户定义转换序列 —–> 和int最接近的有转换关系的类型只有bool了,调用这个
  3. 第二标准转换序列 —–> 得到了bool,目标的int,正好有规则可用,进行转换

因此你的value只会有两种值,0和1。这就是隐式转换带来的第一个大坑,而上面代码反应出的问题叫做“安全bool(safe bool)”问题。

我们可以用explicit把它踢出转换序列。

1
2
3
4
5
6
7
8
template <typename T>
struct SmartPointer {
    //...
    T *ptr = nullptr;
    explicit operator bool() {
        return ptr != nullptr;
    }
};

https://www.cnblogs.com/apocelipes/p/14415033.html#%E4%BB%80%E4%B9%88%E6%98%AF%E9%9A%90%E5%BC%8F%E7%B1%BB%E5%9E%8B%E8%BD%AC%E6%8D%A2

  • 注意引用和隐式类型转换的坑。查看下面的引用和指针的区别。

临时对象:

一般来说临时对象有三种情况产生:

  1. 以值的方式(包括常量引用)给函数传参

  2. 隐式类型转换

  3. 函数返回一个对象时

析构函数和构造函数中的异常

  • 构造函数中可以抛出异常,但是抛出的异常会导致析构函数无法被调用。因为被视为对象没有成功构造。会存在内存泄漏风险
  • 在析构函数中是可以但是是极度极度不推荐抛出异常的。原因在《More Effective C++》中提到两个:

(1)如果析构函数抛出异常,则异常点之后的程序不会执行,如果析构函数在异常点之后执行了某些必要的动作比如释放某些资源,则这些动作不会执行,会造成诸如资源泄漏的问题。

(2)通常异常发生时,c++的异常处理机制在异常的传播过程中会进行栈展开(stack-unwinding),因发生异常而逐步退出复合语句和函数定义的过程,被称为栈展开。在栈展开的过程中就会调用已经在栈构造好的对象的析构函数来释放资源,此时若其他析构函数本身也抛出异常,则前一个异常尚未处理,又有新的异常,会造成程序崩溃。

inline与宏区别?

  • inline:
    • 编译期进行展开。
    • 是函数
    • 嵌入到目标代码
    • 可以进行安全检查,语句正确等编译功能
  • 宏:
    • 预处理期进行替换。
    • 不是函数
    • 只是文本替换
    • 不能进行安全检查等。

inline函数与普通函数区别?

当普通函数在被调用时,系统首先跳跃到该函数的入口地址,执行函数体,执行完成后,再返回到函数调用的地方,函数始终只有一份。

而内联函数则不需要进行一个寻址的过程,当执行到内联函数时,此函数展开(很类似宏的使用),如果在 N 处调用了此内联函数,则此函数就会有 N 个代码段的拷贝。空间换时间。

inline 额外的区别

  • inline 只意味着在程序中函数的定义可以出现很多次。

直接转载至此处

作者:吼姆桑 链接:https://www.zhihu.com/question/24185638/answer/2404153835 来源:知乎

前言

我曾在很长一段时间对inline关键字的认识都维持在相当朴素的程度:用来建议编译器将函数内联展开至调用处。这种理解在早期的C++标准(C++98之前)里问题不大,但在C++98标准以后就完全不对了。首先,现代的编译器在决定是否将函数调用进行内联展开时,几乎不参考函数声明中inline修饰符;其次,inline关键字不仅能修饰函数,也可修饰变量(C++17以后),甚至能修饰命名空间(C++11以后);此外,inline更为关键的作用是允许同一个函数或变量的定义出现在多个编译单元之中;最后,修饰命名空间的inline关键字为程序库的版本控制提供了语言层面上的支持,这与最初的函数内联的含义更是相去甚远。

inline函数

  • 我们知道,若一个非static函数在多个编译单元中被重复定义,那么在链接阶段会发生multiple definition的错误,这是因为面对同一符号的多个定义,链接器并不知道使用哪个。但是对于header-only的程序库来说,所有的函数定义都位于头文件,若不加特殊处理被多个源文件包含,则一定会发生multiple definition的错误。解决这个问题的方法是在函数声明处加上inline修饰,这样的话编译器在处理该函数时就会认为它是一个弱符号,链接器在面对多个名字相同的弱符号时只会保留其中一个的定义(具体的保留规则视编译器而定)。我用一个例子来说明inline函数的编译行为:inline函数foo定义在头文件foo.h中,且函数内部定义了局部静态变量factor;源文件bar1.cc和bar2.cc分别包含了foo.h,并在bar1bar2函数中都调用了foo函数;最后,源文件main.cc中的main函数调用了bar1bar2函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* foo.h */
inline int foo(int x) {
    static int factor = 1;
    return x * (factor++);
}

/* bar1.cc */
#include "foo.h"
int bar1() {
    return foo(1);
}

/* bar2.cc */
#include "foo.h"
int bar2() {
    return foo(2);
}

/* main.cc */
int Bar1(), Bar2();
int main() {
    return Bar1() + Bar2();
}

用gcc编译这三个源文件并链接生成可执行文件main,链接过程中并不会发生multiple definition的错误,并且main函数的返回值表明两次调用使用了同一个局部静态变量factor。

1
2
3
4
g++ -c main.cc bar1.cc bar2.cc -fno-gnu-unique  # ok
g++ -o main main.o bar1.o bar2.o    # ok
./main; echo $? # 5
readelf -s main

最后我们用readelf查看输出的可执行main文件的符号表,可以发现main中确实只有一份符号Foo的定义,同时Foo中的静态变量factor也同样只保留了一份。此时函数Foo和内部定义的局部静态变量factor都是weak符号。

1
2
3
4
5
6
Num:    Value          Size Type    Bind   Vis      Ndx Name
...
49: 0000000000004010     4 OBJECT  WEAK   DEFAULT   23 _ZZ3FooiE6factor
...
53: 000000000000115f    32 FUNC    WEAK   DEFAULT   14 _Z3Fooi
...

当然,没有规定强制多个编译单元中的同名inline函数的定义必须一致,链接器并不会对这种定义不一致的行为报错,但你却无法保证生成的可执行文件中调用了哪个版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* bar1.cc */
inline int Foo(int x) {
    static int factor = 1;
    return x * (factor++);
}
int Bar1() {
    return Foo(1);
}

/* bar2.cc */
inline int Foo(int x) {
    static int factor = 2;
    return x * (factor++);
}
int Bar2() {
    return Foo(2);
}

/* main.cc */
int Bar1(), Bar2();
int main() {
    return Bar1() + Bar2();
}

对于上述例子,9.3.0版本的gcc面对这种情况很可能是根据源文件的编译顺序而决定使用哪个Foo:

1
2
3
4
g++ -o main main.cc bar1.cc bar2.cc -fno-gnu-unique # ok
./main; echo $? # 5
g++ -o main main.cc bar2.cc bar1.cc -fno-gnu-unique # ok
./main; echo $? # 8

所以面对这种其它编译单元可能定义了同名inline函数的情况,不要轻易定义该名字的inline函数,因为你既无法保证对方的定义与你相同,也无法保证链接器最终选择的定义。如果非要定义,应该将其声明为static或者将其声明定义在另一个不冲突的命名空间中。当然,不使用任何关键字修饰该函数也不行,因为这时你定义的版本对应的符号是全局的强符号,链接器在面对多个弱符号和一个强符号时一定会采用强符号对应的定义,因此你定义的版本会覆盖其它单元所定义的inline版本。除非你知道这样的后果是你所需的(确实我们有时候会这么做,例如覆盖掉程序库提供的默认版本),否则不要这样做。

inline变量

inline用于修饰变量定义是在C++17之后的事情。当inline用于修饰变量定义时,你很难说它具有内联展开的作用,因为将变量的定义内联展开到引用处没有意义,它更多地只是允许在多个编译单元对同一个变量进行定义,并且在链接时只保留其中的一份作为该符号的定义。当然,同时在多个源文件中定义同一个inline变量必须保证它们的定义都相同,否则和inline函数一样,你没办法保证链接器最终采用的是哪个定义。inline变量除了允许我们在头文件中定义全局变量,也允许我们在类定义中直接初始化静态数据成员:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* foo.h */
struct Foo {
    inline static int foo = 1;
};
/* bar1.cc */
#include "foo.h"
int Bar1() { return Foo::foo + 1; }
/* bar2.cc */
#include "foo.h"
int Bar2() { return Foo::foo + 2; }
/* main.cc */
int Bar1(), Bar2();
int main() {
    return Bar1() + Bar2();
}

我们用gcc编译上述文件

1
2
g++ -std=c++17 -c main.cc bar1.cc bar2.cc # ok
g++ -std=c++17 -o main main.o bar1.o bar2.o # ok

并用readelf分别查看各个目标文件,我们会发现这个内联的静态数据成员在bar1.o和bar2.o中都以弱符号的形式单独存在于一个段中,在链接后main只会包含一个foo符号,并且该符号对应的数据被合并到了.data段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 以下为 readelf -sS bar1.o | grep foo 和readelf -sS bar2.o的内容 
Section Headers:
[Nr] Name               Type             Address           Offset
...
[ 6] .data._ZN3Foo3fooE PROGBITS         0000000000000000  0000005c
...
Symbol table '.symtab' contains 13 entries:
Num:    Value          Size Type    Bind   Vis      Ndx Name
...
11: 0000000000000000     4 OBJECT  WEAK   DEFAULT    6 _ZN3Foo3fooE
# 以下为readelf -s main | grep foo
Num:    Value          Size Type    Bind   Vis      Ndx Name
...
0000000000004010     4 OBJECT  WEAK   DEFAULT   23 _ZN3Foo3fooE

可以看见在bar1.o和bar2.o中内联的静态数据成员被单独放到了名为.data._ZN3Foo3fooE的段中,这种将内联变量单独放置一个段的原因主要是为了在链接时消除重复代码,在链接合并段的时候面这些特殊的同名段会选择性地保留其中一个。

inline命名空间

inline命名空间是C++11标准中引入的关键词,对于一个用inline修饰的内嵌命名空间而言,它所包含的成员在可见性上如同声明在外围命名空间中一样,所以inline之于命名空间更具有字面上的含义:将内嵌命名空间在外围命名空间中“展开”。inline命名空间最主要的用途是为程序库的版本控制提供语言上的支持,有点类似于共享库中的符号版本控制(将程序使用的接口版本信息记录到可执行文件,动态链接时再根据这些版本信息从共享库调用正确版本的接口),一般来说库的作者会为不同的版本放置到专属的命名空间,再用一个与版本无关的外围命名空间将它们包含,并通过预编译指令选择性地将开发环境支持的库版本对应的命名空间暴露给用户。例如下面的例子中库libfoo根据宏some_predefined_macro的值将不同版本的接口暴露给用户程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#define some_predefined_macro   2022L

namespace libfoo {
#if some_predefined_macro >= 2022L
    inline
#endif
    namespace libfoo_2022 {
        int foo(int);       // better foo
        float foo(float);   // new foo
    }
#if some_predefined_macro >= 2019L && some_predefined_macro < 2022L
    inline
#endif
    namespace libfoo_2019 {
        int foo(int);   // old foo
    }
// other versions ...
}

using namespace libfoo;
int main() {
    printf("foo(5) = %d\n", foo(5));    // refer to int libfoo::libfoo_2022::foo(int);
    // ...
}

当然,这种选择性地暴露内嵌命名空间成员乍一看也可以通过using namespace来完成,在C++11之前库的版本控制就是通过它来实现的。但用这种方式实现库的版本控制是有两个明显的缺陷:(1)在内嵌命名空间声明的模板无法在外围命名空间中进行特化,(2)不支持ADL。一般来说库作者都不希望将版本相关的命名空间暴露给用户,而缺陷(1)又要求用户必须在模板所在的命名空间中对其进行特化,例如说下面这段代码就不行:

1
2
3
4
5
6
7
8
9
10
11
12
namespace libfoo {
    namespace libfoo_2022 {
        template <typename T>
        T &foo(T &);
    }
    using namespace libfoo_2022;
}

namespace libfoo {
    template <>
    float &foo<float>(float &); 
}

ADL的意思是在函数名字查找时自动将调用参数所属的命名空间包含进来,这样在函数调用时便无需显示指定作用域。那缺陷(2)意思是用内嵌命名空间的类型变量作为参数调用外围命名空间的函数,或者用外围命名空间的类型变量作为参数调用内嵌命名空间的函数是行不通的,例如下面这段代码也不行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
namespace libfoo {
    class Bar1 {};
    namespace libfoo_2022 {
        void foo1(Bar1);
        class Bar2 {};
    }
    using namespace libfoo_2022;
    void foo2(Bar2);
}

int main()
{
    libfoo::Bar1 bar1;
    libfoo::Bar2 bar2;
    (void)foo1(bar1);    // compile error
    (void)foo2(bar2);    // compile error
    ...
}

inline命名空间可以解决using namespace在实现库版本控制时的缺陷,inline与using的区别在于用inline修饰的内嵌命名空间的成员表现得更像声明在外围命名空间:你不仅可以在外围命名空间中对inline的内嵌命名空间的模板进行特化,而且ADL也会将参数类型所在命名空间所包含的inline命名空间,以及自身作为inline命名空间所在的外围命名空间一并考虑进来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
namespace libfoo {
    class Bar1 {};
    inline namespace libfoo_2022 {
        void foo1(Bar1);
        class Bar2 {};
        template <typename T>
        T &foo(T &);
    }
    void foo2(Bar2);
}

namespace libfoo {
    template <>
    float &foo<float>(float &);
}

int main() {
    libfoo::foo(3.2);       // call specialized libfoo::libfoo_2022::foo
    libfoo::Bar1 bar;
    libfoo::Bar2 bar2;
    foo1(bar1);             // call lib::libfoo_2022::foo1
    foo2(bar2);             // call lib::foo2
    ...
}

inline在修饰命名空间时还有一些别的特点,比如若inline命名空间与外围命名空间包含了名字重复的符号时,使用外围命名空间作为作用域对该符号进行引用将会导致编译错误,因为编译器无法确定你究竟引用了哪个命名空间的符号。但如果使用using namespace的话上述情况则总是指向外围命名空间中的符号。此外inline命名空间还具有传递性,这些具体的特性详见cppreference,但我认为inline修饰符引入到命名空间最主要的作用是在库版本控制时解决ADL和命名空间外模板特化的问题。

typedef和define(宏)有什么区别

  • 用法不同:typedef用来定义一种数据类型的别名,增强程序的可读性。define主要用来定义常量,以及书写复杂使用频繁的宏。
  • 执行时间不同:typedef是编译期处理,有类型检查的功能。define是宏定义,是预编译的部分,其发生在编译之前,只是简单的进行字符串的替换,不进行类型的检查。
  • 作用域不同:typedef有作用域限定。define不受作用域约束,只要是在define声明后的引用都是正确的。
  • 对指针的操作不同:typedef和define定义的指针时有很大的区别。

注意:typedef定义是语句,因为句尾要加上分号。而define不是语句,千万不能在句尾加分号。

如何加深理解移动构造和拷贝构造

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
class myobj{
    public:
        myobj(int x):val(new int(x)){}
        int& getval(){
            return *val;
        }
        myobj(const myobj& obj){						//拷贝构造
            val = new int(*obj.val);					
        }
        myobj& operator=(const myobj & rhs){			//拷贝赋值
            if(this == &rhs){
                return *this;
            }
            delete this->val;
            val = new int(*rhs.val);
            return *this;
        }
        myobj(myobj&& obj){								//移动构造
            cout <<"mv" << endl;
            val = obj.val;
            obj.val = nullptr;
        }
        myobj& operator=(myobj&& rhs){					//移动赋值
            cout <<"mv=" << endl;
            if(this == &rhs){
                return *this;
            }
            if(this->val != nullptr){
                delete val;
            }
            val = rhs.val;
            rhs.val = nullptr;
            return *this;

        }
        ~myobj(){
            delete val;
            val = nullptr;
        }


        int* val;
        
};

int main(){
    myobj a(5);
    myobj b(10);
    cout << a.getval() << endl;					
    myobj c(a);							//拷贝构造
    cout << c.getval() << endl;
    myobj d = c;						//拷贝构造。因为d对象之前不存在。
    cout << d.getval() << endl;
    d = b;								//拷贝赋值
    cout << d.getval() << endl;
    myobj e = move(a);					//移动构造。因为e对象之前不存在。在这之后不可访问a,因为a所有权已被转移
    cout << e.getval() << endl;
    d = move(b);						//移动赋值。在这之后不可访问b,因为b所有权已被转移
    cout << d.getval() << endl;
    return 0;
}

首先我们要强化记忆,什么叫构造函数。构造函数就是创建对象的时候调用的。我们这里有一个类,里面有一根指针。首先,我们既然是构造,那么拷贝构造和默认构造都要对变量(指针)初始化(分配内存)。所以这里拷贝构造和默认构造都是new的。因为我们都是无中生有

那么移动构造和拷贝构造的区别在哪呢?

什么时候我们想要移动?假如我们要转移所有权,我们这时候会调用移动构造。也是无中生有。但是由于转移所有权,所以我们不需要new。我们只需要把原来指针指向的地址赋值给我们新变量的对应变量上即可。移动之后,因为我们的目的是转移所有权,所以应当对原对象的对应指针变量置空。

拷贝赋值和移动赋值。

  • 拷贝赋值我们首先要检测自我赋值。通过地址来判断。如果是一个地址互相赋值,那么什么都不做。然后我们首先把自己的东西清理掉,也就是自己指针指向的资源释放掉。然后,由于我们是拷贝,不是移动。所以我们依旧要开辟新空间,把原来对象的指针指向的值复制过来。

  • 移动赋值,我们首先也要检测自我赋值。然后也要先把自己的东西清理掉,也就是自己指针指向的资源释放掉。然后,由于我们是移动,所以我们直接复制指针而不是指针指向的值。然后把源对象的指针置空,因为我们是转移所有权。

  • 返回值:

    • 构造函数,无论是默认构造,拷贝构造还是移动构造,函数都无返回值。

    • 赋值操作符,无论是拷贝赋值还是移动赋值,我们为了链式调用,也避免不必要的拷贝,都应该返回对象的引用。

  • 形参:

    • 拷贝构造和拷贝赋值,应该接受对象的常引用。

    • 移动构造和移动赋值因为涉及到对源对象的修改,即 对源成员变量(指针)置空,所以不能是常量。而且要接收右值。因为是移动。

  • 什么时候该触发移动构造呢?

    • 如果临时对象即将消亡,并且它里面的资源是需要被再利用的,这个时候我们就可以触发移动构造。
      • 比如vector.push_back(obj(8));这就是往容器添加一个临时对象。这时候如果此临时对象有移动构造,则调用完默认构造后,直接触发移动构造进行搬移省去一次内存分配。否则需要调用拷贝构造。
    • 移动构造在使用vector的情况下,一般是vector扩容的时候重新分配内存的时候使用,如果元素的自定义类型有移动构造就会用移动构造。没有就是拷贝构造。这里的移动构造必须是noexcept

针对自定义类强制生成移动构造

针对自定义类强制生成移动构造会满足member-wise move。也就是移动。它会帮我们调用每一个成员的移动构造。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class myobj{
    public:
        int val; //基本数据类型
        string str; //自定义类型可移动
        unique_ptr<int>u_ptr = nullptr; //智能指针,也算自定义类型可移动

        myobj(int x, const string& rhsstr, int num): val(x), str(rhsstr), u_ptr(make_unique<int>(num)){
            cout << "const" << endl;
        }
        myobj(myobj&& rhs) = default; //强制生成移动构造。
};
int main(){
    myobj obj(4, "abc", 22);
    cout << *obj.u_ptr << endl;
    cout << obj.str << endl;
    return 0;
}
/*
输出
const
22
abc
*/

如果决定移动它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int main(){
    myobj obj(4, "abc", 22);
    cout << *obj.u_ptr << endl;
    myobj obj1(move(obj)); //移动后
    if(obj.u_ptr != nullptr){
        cout <<"not null" << endl;
    }
    else{
        cout <<"null" << endl;
    }
    cout << obj.str << endl;
    return 0;
}
/*
输出
const
22
null
(这行是空字符串)

*/
  • 我们看到,unique_ptrstring都被正确移动了。原对象的智能指针为空,字符串也为空。

但是,裸指针(指针类型)并没有移动构造,所以必须写出我们自己的移动构造。包括两个部分,一个部分是设置为default强制生成构造函数可以帮我们完成的member wise move。另一个部分是给原始指针进行置空

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class myobj{
    public:
        int val; //基本数据类型
        string str; //自定义类型可移动
        int* ptr = nullptr; //注意此处替换为原始指针

        myobj(int x, const string& rhsstr, int* num): val(x), str(rhsstr), ptr(num){
            cout << "const" << endl;
        }
        myobj(myobj&& rhs) = default;
        ~myobj(){
            delete ptr;
            ptr = nullptr;
        }
};
int main(){
    myobj obj(4, "abc", new int(22));
    cout << *obj.ptr << endl;
    if(obj.ptr != nullptr){
        cout <<"not null" << endl;
    }
    else{
        cout <<"null" << endl;
    }
    cout << obj.str << endl;
    return 0;
}
/*
输出
const
22
notnull
abc
*/

如果我们决定移动它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int main(){
    myobj obj(4, "abc", new int(22));
    cout << *obj.ptr << endl;
    myobj obj1(move(obj));
    cout << *obj1.ptr << endl;
    if(obj.ptr != nullptr){
        cout <<"not null" << endl;
    }
    else{
        cout <<"null" << endl;
    }
    cout << obj.str << endl;
    return 0;
}
/*
输出
const
22
22 //此行为obj1的输出。也是22
notnull
(这行是空字符串)
*/
  • 我们看到,string被正确移动了。原对象的字符串为空。但是由于原始指针没有自己的移动构造,所以编译器只能帮助我们进行member wise move,无法帮助我们给原始指针进行置空。

所以我们要这么写:

1
2
3
4
5
6
myobj(myobj&& rhs): 
    val(move(rhs.val)), //基本数据类型,这么写永远不会错。虽然没什么用。
    str(move(rhs.str)),
    ptr(move(rhs.ptr)){ //原始指针类型,这么写永远不会错。虽然没什么用。
        rhs.ptr = nullptr;
}

于是就会有正确结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int main(){
    myobj obj(4, "abc", new int(22));
    myobj obj1(move(obj));
    cout << *obj1.ptr << endl;
    if(obj.ptr != nullptr){
        cout <<"not null" << endl;
    }
    else{
        cout <<"null" << endl;
    }
    cout << obj1.str << endl;
    return 0;
}
/*
输出
const
22 //此行为obj1的输出。也是22
null //obj的指针已经空了。
abc //此行为obj1的字符串。正确移动了。
*/

https://youtu.be/St0MNEU5b0o

for循环

1
2
3
4
for ( init; condition; increment )
{
   statement(s);
}

for循环的更新条件(第三个条件)是迭代之后执行的,也就是最后执行的。

重要优化 for循环的判断表达式(第二个条件)是每一次循环都会执行。如果判断表达式中有函数调用则每次都会调用

如下面代码,for循环调用了五次,则func函数也会被调用5次。

1
2
3
4
5
6
7
8
9
10
11
int func(int x) {
    cout <<"call" << endl;
    return 5;
}
int main() {

    for (int i = 0; i < func(i); i++) {
        cout << "for" << endl;
    }
    return 0;
}

i++和++i

  • ++i ++i 先自加,再赋值。
1
2
3
4
5
6
7
8
#include<stdio.h>
int main()
{
    int i = 0;
    int j = ++i;    
    cout << i << j <<endl;
    return 0;
}

此时输出 i=1 j=1

  • i++

​ i++ 先赋值,再自加。

1
2
3
4
5
6
7
8
#include<stdio.h>
int main()
{
    int i = 0;
    int j = i++;
    cout << i << j <<endl;
    return 0;
}

此时输出 i=1 j=0

i++是右值,++i是左值

在内置类型的情况下

1
int a = i++;

这句话在编译器眼里是下面这样的两句话:

1
2
int a = i;
i++;

1
int a = ++i;

在编译器眼里则是这样:

1
2
i++;
int a = i;

i++和++i在非自定义类型的情况下,只要不发生赋值,则原理没有差别。但是性能有差别。

我们深入看一下自定义类型的情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct mybase
{
    // 前置++
    mybase& operator++()
    {
        data++;
        return *this;
    }

    // 后置++
    const mybase operator++(int) //注意返回const
    {
        auto tmp = *this;
        // 执行++有两种选择, data++或者调用上面的前置++
        data++;

        return tmp; //注意这里注意这里temp是局部变量,不可返回引用!!
    }

    int data = 0;
};

注意这里后置的++我们有一个非常奇怪的写法。我们用一个临时变量保存了自己,然后再把自己的值+1,然后返回一个自己的拷贝。不能返回引用!因为这里的temp是临时变量,返回引用就炸了

同时应为其添加const。原因是i++++这样的调用并不应该合法。首先因为它不符合如int这样的内置类型的语义。其次,i++++的第二个++是作用于i++返回值的临时对象身上的,所以最终i也只会被++一次。

这也就是为什么i++先赋值再自增了。因为我们赋值过去的是一个没有自增的自己。

这也是为什么i++是一个右值。因为他符合了右值的定义之一:返回了一个非引用类型的 函数调用。因为++本身也可以重载,也是函数调用。他返回了一个临时对象的自己。

所以++i反而看起来非常简单。我们先给自己+1然后返回自己的引用。这也是为什么++i是先自增再赋值,也是为何++i是左值。

性能区别

  • 内置类型(比如int):

    • 编译器会优化,所以前置++和后置++性能无差别。
  • 自定义类型(STL迭代器):

    • 前置++操作的性能优于后置++
    1
    2
    3
    4
    5
    6
    7
    8
    
    __normal_iterator& operator++(){  // 前置操作
        ++_M_current;
        return *this;
    }
      
     __normal_iterator operator++(int){ // 后置操作
         return __normal_iterator(_M_current++); 
     }
    
    • 上面是STL中iterator的源码。从上面代码可以看出,迭代器的前置和后置操作主要有以下两个区别:
      • 返回值:前置操作返回对象的引用,后置操作返回类型为对象,
      • 拷贝:前置操作无拷贝操作,后置操作存在一次对象拷贝
    • 正因为这两个原因,前置操作符就地修改对象,而后置操作符将导致创建临时对象,调用构造函数和析构函数(某些情况下编译器会做优化,此处不做讨论),导致了前置操作和后置操作的性能差异。
  • 其他自定义类型:

    • 前置++操作的性能优于后置++。

编译过程

记住ISO即可

.h/c/cpp通过 预处理器处理为.i的预处理代码

.i的预处理代码通过编译器处理为.s的汇编代码

.s的汇编代码通过汇编器处理为.o的目标代码(可重定位二进制)

.o的目标代码通过链接器生成可执行文件.exe/.out (如果没有外部代码或者dll/lib库文件就直接生成可执行文件)

从cpp源代码到可执行程序经过了哪些过程?

A: 在Linux系统下从.cpp开始, 经过

1.预处理阶段, 在这部分主要处理include的文件包含关系, 还有宏的替换, 做完.cpp变为.i 2.编译阶段会做语法分析还有优化之类的, 将代码转换为汇编, 做完.i变.s 3.汇编阶段会把前面得到的汇编代码转换为二进制的机器代码, 做完.s变.o 4.最后运行链接器将所有可重定位文件合并为一个可执行文件, 做完.o变prog. 对于可执行文件可以调用系统中被称为加载器的函数来将文件复制到内存并开始执行.

其中自己了解较多的是最后的链接阶段. 链接器为了将输入的多个可重定位文件组合为一个可执行文件, 要做的事主要有两个, 一是符号解析, 二是重定位. 其中符号解析意思是说一个文件里会有很多对符号的定义和引用, 所谓符号就是全局变量,函数和静态变量这些. 然后符号解析就是将每个符号引用与一个符号定义关联起来. 而重定位做的事是把每个符号定义与一个内存地址关联起来, 然后将所有对这个符号的引用都指向这个地址.

详细说符号解析的话需要先说链接器的输入, 也就是可重定位文件的文件格式. 虽然它是二进制文件, 但实际上是有各种分段的, 比如有个固定的头部, 描述了文件类型, 机器类型等等一些主要信息. 还有放机器代码的段, 再往下还有放全局变量的段等等. 而文件中会有一个放符号表的段, 其中存放了这个文件定义和引用的全局变量以及函数这些信息.

符号表中有三类不同的符号主.分别是1.由当前文件定义的全局符号(定义的全局变量,函数等) 2.引用的其他文件定义的全局符号, 3.只被当前文件定义和引用的符号(static全局变量和函数). 每个符号条目并不是只有一个名字, 而是有一个固定的格式结构. 这个结构会标明符号名字, 大小, 类型(变量还是函数), 链接性(全局变量是GLOBAL, static全局变量大概会是LOCAL)等等.(之前看到过一个问题是说为什么static全局变量可以把作用域限制在本文件, 讲到这里就可以回答这个问题了. 因为static符号被单独分了一类, 链接器不会拿static符号去跟别的文件做解析.)

符号解析就是利用这些符号表信息去做解析的. 对于那些定义和引用都在同一文件的符号解析很简单. 但是本文件引用了其他文件定义的符号就会复杂一些. 编译器会把这样的只有引用的符号交给链接器, 由链接器去其他文件找这个符号的定义, 如果找不到就会报错. 符号解析还有一个问题是重名问题, 编译器在编码符号的时候会给他一个强弱属性, 已初始化的全局变量就是强, 未初始化的就是弱. 然后如果重名, 链接器去选择的时候会优先选择强符号, 如果都是弱符号, 那就随便选一个. 但如果两个重名符号都是强那会直接报错.

符号链接还可以跟静态库链接(静态库就是提前写好的一些函数文件打包为一个单独文件, 可以用它来当作链接器输入). 在链接静态库的时候, 链接器会维护三个列表: 1.可重定位文件的列表, 2.未解析的符号(有引用但还没定义的符号) 3.已定义的符号. 链接器会从左向右扫描文件然后用这三个表来做符号解析. 这其中要注意的问题是文件的顺序很重要, 顺序不对了解析会失败.

上面就是符号解析的说明了, 接下来是重定位部分. 重定位要做的就是把文件合并一下,然后给每个指令和符号分配内存地址. 再然后就是把对符号的引用指向刚才分配好的地址.

重定位做完就可以得到一个可执行的目标文件.文件由三个大部分组成: 1.只读代码段, 2.读写数据段, 3.不加载进内存的符号表和调试信息这些. 而将一个可执行文件加载到内存后所呈现的布局主要有四个大的部分. 从下到上分别是:1.只读代码段, 2.读写数据段(用来放全局变量, 细分为已初始化和未初始化的两部分), 3.堆, 4.栈. 然后在堆栈之间还有预留给动态库的内存映射区域. 最后在栈的上面还有用户不可见的内核内存.

整数相除

两个整数相除,无论用什么接都是整数。因为会自动窄化。所以想要让两个整数相除保留小数点,则必须转换成double或float再进行除法操作

1
2
3
4
5
6
7
8
int a = 3;
int b = 6;
double c = a/b;
// C = 0;
double a = 3;
double b = 6;
double c = a/b;
// C = 0.5

形参(parameter)和实参(argument)区别

  • 形参 parameter是在函数头的参数
1
2
3
void func(int parameter){
    
}

这里我们明显看到了parameter的位置

  • 实参 argument是传递的参数
1
2
int argument = 10;
func(argument);

这里我们明显看到了argument的位置

函数参数(parameter)的默认值的继承

c++的函数参数的默认值是不可以被继承的。表面上可以使用只是“借用”,但不是继承。同时会有奇怪调用。 我们看下面的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct A {
  virtual void f(int a = 7){
    cout << a;
    cout <<"A" << endl;
  }
};
struct B : public A {
  void f(int a){
    cout << a;
    cout << "B" << endl;
  }
};
int main() {
    A* pa = new A;
    B* pb = new B;
    A* m = new B;

    pa->f();    //7A
    pa->f(10);  //10A

    pb->f();    //error: no matching function for call to ‘B::f()’
    pb->f(10);  //10B

    m->f();     //7B
    m->f(10);   //10B
}
  • 虚函数调用中使用的默认参数(argument,参数的默认值)由对象的(指针或引用)的静态类型决定。 派生类中的重写的虚函数不会从被它重写的虚函数中获取默认参数。来自标准库文档

    • 表面上的参数默认值的继承其实是借用,而不是继承。pb->f(),参数的默认值从静态类型决定。所以此处参数的默认值应该是从B类寻找。但是B类重写的函数的参数没有默认值,导致没有合适的值放入函数调用。函数签名不匹配,无法调用。
  • 当父类虚函数和子类虚函数参数都有默认值,但是值不同的时候,如果产生了多态调用,则此时会忽略子类默认值,使用父类默认值,但是实际调用的是子类函数。也就是在使用父类指针指向的对象调用虚函数时,函数会按实际指向的对象调用对应的成员函数(动态绑定的结果),而函数的默认参数依旧为父类对象中的默认参数(静态绑定的结果) 所以永远不要重新定义一个继承而来的默认参数值,因为默认参数值是静态绑定的,而虚函数——你应该重新定义的唯一的函数——是动态绑定的。effective C++ 37条

类(对象)内存模型:

大小

  • 类内存大小有关的:

    • 非静态数据成员(包括常量)。也就是常量会对类大小有影响。

    • 虚函数表指针(虚函数产生)

    • 虚基表指针(虚继承产生)

  • 类内存大小无关的:

    • 成员函数
    • 静态成员函数
    • 静态数据成员
    • 静态常量数据成员
  • 空类大小为1,为了分配一个内存起始地址

    • 空类被继承后,会有空基类优化,所以空基类的占用大小会变成0。前提是当前继承空基类的类不是空类。
    1
    2
    3
    4
    5
    6
    
    class x {};
    class y:public x{
        int a;
    };
    //sizeof(x) = 1
    //sizeof(y) = 4
    
    • 类内如果含有空类的对象,则空类对象大小依旧会被算为1。
    1
    2
    3
    4
    5
    6
    7
    8
    
    class x {};
    class y:public x{
        x obj
        int a;
    };
    //sizeof(x) = 1
    //sizeof(y) = 8
    //基类对象 1 + int 4 + 补齐 = 8
    
    • 如果是链式空基类继承,则所有的空类大小都为1。
    1
    2
    3
    4
    5
    6
    
    class x {};
    class y:public x{};
    class z:public y{};
    //sizeof(x) = 1
    //sizeof(y) = 1
    //sizeof(z) = 1
    

内存对齐

C++类成员变量的内存分布是:按照声明顺序,从上到下,按照内存对齐的原则进行分布的。

用预编译命令#pragma pack(n) 或结构体名字前加 alignas(n)用来指定对齐系数(单位)为n个字节。n的取值范围为1, 2, 4, 8, 16。gcc默认对齐系数是4,msvc默认对齐系数是8

1
2
3
4
5
struct alignas(4) EbaInfo {
    uint8_t eba_switch;
    uint8_t eba_state;
    uint8_t eba_direction;
};
  1. 内存分配的顺序是按照声明的顺序。
  2. 类中第一个成员的偏移量(offset)为0,以后每个成员(该成员的数据类型长度为k)相对于结构体首地址的offset为min(k, s)的整数倍。
  3. 如果一个类里有结构体成员,则结构体成员要从其内部最宽基本类型成员的整数倍地址开始存储。
  4. 整体对齐规则:整个结构体的大小应是对齐单位s的整数倍。

检查结构体对齐单位可以使用alignof()。 使用g++ -fdump-lang-class *.cpp生成布局信息文件。

举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class A {
  char a;
  int b;
  double c;
};

class B {
  char a;
  double b;
  int c;
};

int main() {
  cout << sizeof(A) << endl; //16
  cout << sizeof(B) << endl; //24
}

A类的大小:

  1. char a占1个字节,偏移值为0,目前A类占1字节。
  2. int b占4个字节,根据内存对齐原则的第二条,b起始位置的偏移量应该4的整数倍,所以b要在a后面空3个字节,然后放入,因此偏移值为4,目前为止A类占用8个字节(3个padding)。
  3. double c占8个字节,可以直接从b后的第一个字节开始放入,偏移值为8。此时A类占用16字节,符合内存对齐的第三条原则,整个类的大小(16)是对齐单位(8)的整数倍,因此不需要补齐。所以A类总共占用16字节。

同理,B类的大小:

  1. char a占1个字节,偏移值为0,目前B类占1字节。
  2. double b占8个字节,根据第二条规则,b要在a后面空7个字节,然后开始放入,因此偏移值为8,目前为止B类占用16个字节(7个padding)。
  3. int c占4个字节,可以直接从b后的第一个字节开始放入,偏移值为16,此时B类占用20个字节。根据内存对齐的第三条原则,整个类的大小应该是对齐单位(8)的整数倍,因此需要补齐3个字节到24字节。所以B类总共占用24字节。

带有虚函数的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
class D {
public:
  virtual void funA();

private:
  char a;
  int b;
  double c;
};

int main() {
    cout << sizeof(D) << endl; //24
}
  1. vptr占8个字节,偏移值为0,目前D类占8字节
  2. char a占1个字节,偏移值为8,目前D类占9字节
  3. int b占4个字节,根据内存对齐原则的第二条,b起始位置的偏移量应该4的整数倍,所以b要在a后面空3个字节,然后放入,因此偏移值为12,目前为止D类占用16个字节(3个padding)。
  4. double c占8个字节,可以直接从b后的第一个字节开始放入,偏移值为16。此时D类占用24字节,符合内存对齐的第三条原则,整个类的大小(24)是对齐单位(8)的整数倍,因此不需要补齐。所以D类总共占用16字节。

所以在某种极端情况,如这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class a{
    char a;
    int b;
    char c;
    int d;
    char e;
    int f;
    char g;
    int h;
};

class b{
    char a,b,c,d;
    int e,f,g,h;
};


int main() {
    cout << sizeof(a) << endl; 	//32
    cout << sizeof(b) << endl;	//20
    return 0;

}

因为根据规则,a的每一个char后都需要补3位为了满足每一个变量的起始偏移地址必须为该变量大小的整数倍。这里int是4,所以必须要补到4这样是1倍。所以每一个char都占据了一个int空间。所以整个class相当于是4*8 = 32。

然而b的四个char连续放置。1是1的整数倍所以不需要补齐操作。所以只有4+4*4 = 20。

由此可见,变量的声明顺序会极大地影响类的内存布局和类对象的大小。

出现在派生类中的 base class subobject 有其完整原样性

无论是否是多继承下,每一个继承的类,会先进行对齐,然后再进行下一个继承。

假设类A是(8+1)9 + 补齐 = 16,类B也是9 + 补齐 = 16, 类C继承了类A和类B,所以类C是32 (多继承)

假设类A是(4+1)5 +补齐 = 8,类B继承了类A但是类B自己也是5,则会变成类A的8 + (4+1)+ 补齐的3 = 16, 类C继承了类B然后自己是4,就是 16 + 4 = 20。

因为C++ Standard 保证:“出现在派生类中的 base class subobject 有其完整原样性!” 子类会被放在父类的对齐空白字节之后,因为父类的完整性必须得以保证,父类的对齐空白字节也是父类的一部分,也是不可分割的。所以会保持继承->对齐->继承第二个->对齐…. 深入探索C++对象模型

类内函数内声明的变量(局部变量)作用域是函数而不是类

所以类的函数内的变量不会算作类的大小。

1
2
3
4
5
6
class test{
    public:
    void funcs(){
        int val;
    }
};

大小一定是1。

NRV优化

NRV优化一句话就是,在函数返回值是值传递的时候,按理来说需要有临时变量调用拷贝构造。编译器对这些事情有优化。

内置类型:

int double ,指针等内置类型,使用eax寄存器直接储存,然后访问

自定义类型:

一句话:编译器会把函数返回值当成形参,然后直接写回。

举例:

1
2
3
void myfunc(solution& ret, solution& a, solution& b);

solution myfunc1(solution&a, solution& b);

我们的myfuncmyfunc1起到了同样作用,只不过我们人为写成两种形式,一种是返回值,一种是写回。编译器也是优化成了这样,把我们外部接着返回值的变量以引用(这样才能写回)方式当做函数形参,然后函数仅需写回即可。

举例:

原函数和调用代码

1
2
3
4
5
6
7
8
9
10
11
12
Vector add(Vector& a, Vector & b)
{
    Vector v;
    v.x = a.x + b.x;
    v.y = a.y + b.y;
    return v;
}
int main(){
    Vector a, b;
	Vector c = add(a, b);
    return 0;
}

编译器优化过的函数和调用代码:

1
2
3
4
5
6
7
8
9
10
11
12
void add(Vector& __result, Vector& a, Vector & b)
{
    __result.x = a.x + b.x;
    __result.y = a.y + b.y;
    return;
}
int main(){
    Vector a, b;
    Vector c;
    add(c, a, b);
    return 0;
}

https://www.cnblogs.com/autosar/archive/2011/10/09/2204181.html

类的私有静态变量可以通过作用域访问运算符直接访问

main函数结束也会为栈对象调用析构,但是不会为堆对象调用。

类内函数内声明的变量(局部变量)作用域是函数而不是类

所以类的函数内的变量不会算作类的大小。

指针和引用的区别:

引用从来都不是所谓的“指针包装器”。它们的行为完全不同。是完全不同的两种数据类型。

  • 可以把引用看成一个const指针。因为是const指针所以必须定义时初始化,也不能更改指向。但是这个并不全对。比如

    • 1
      2
      3
      
      double p = 3.33;
      int& ref = p; //错误
      int&& ref = p;//可以。
      
    • 我们上面提到了。隐式类型转换会创建临时对象。这里就是调用了int的形参为double的构造函数创建了一个临时对象。临时对象是右值。右值没有地址。我们这里用的又是左值引用,自然会产生错误。

    • 所以我们注意到了,隐式转换的临时变量是右值。所以可以用右值引用去接。但是!!这时候这个ref并不是p的引用,而是一个临时变量的引用。ref此时代表的并不是p而是一个临时对象!
  • 指针是一个单独的实体,引用则是别名

    • 所以指针有地址
    • 引用虽然(可能)也有地址(也分配内存),但是引用和引用所引用的变量共用一块内存空间。当然了,引用本身确实有自己的内存空间,和指针一样。但是经过编译器处理后,访问这块内存时将直接转而访问其指向的内存。因此在程序中无法读取到这块内存本身。(依靠实现)
  • 引用不可以赋值,所以引用只能在定义时初始化一次,之后不可更改。指针可以更改该并且可以把定义和初始化分开。

  • 引用不能为空,指针可以为空。

  • sizeof指针的大小是指针自己的大小。sizeof引用是对象的大小。

  • 没有引用的引用,没有引用数组,也没有指向引用的指针。

    • 下面我们的bc都是a的引用。c并不是b的引用。因为没有引用的引用。
1
2
3
4
5
6
7
8
int main(){
    int a = 10;
    int &b = a;
    int &c = b;
    c = 20;
    cout << a << b << c <<endl; //20 20 20 
    return 0;
}
  • 指针有自增操作。

  • 引用不可以重新赋值(不能更改指向)

1
2
3
4
5
6
7
8
9
10
11
int main(){
    int a = 10;
    int b = 20;
    int c = 30;
    int& aref = a;
    cout << aref << endl; //10
    aref = c; //注意这里!
    cout << aref << endl; //30
    cout << a << endl; //30
    return 0;
}

我们看到arefa的引用,也就是它俩是一个东西。执行aref = c的时候,并不是把aref变成c的引用,而是把c的值赋给aref也就是赋给a的引用。所以最后aaref都会变成30。

  • 使用一个引用给一个变量赋值,则会调用其拷贝构造构造出一个新的变量。因为引用的行为和普通变量一样
1
2
3
int a = 10;
int& b = a;
int c = b; //在这里a会被拷贝然后给c。也就是c会被拷贝构造。因为如果这里换成int c = a,也是会被拷贝构造。引用就相当于变量的别名。所以这里引用和普通变量行为一样
  • 自然而然的,函数的值传递和引用传递可以解释通了
1
2
3
4
5
6
7
8
9
10
void func(int& a);
void func1(int a);

int var = 100;
int& ref = var;

func(var);
func(ref);
func1(var);
func1(ref);
  • 第一个调用:标准的引用传递。
    • 变量var传递进函数func的时候,函数内部会创建一个叫a的引用,引用的对象是外部的var
  • 第二个调用:引用的引用。
    • 引用ref传递进函数func的时候,函数内部会创建一个叫a的引用,引用的对象是引用ref的引用对象也就是var。因为没有引用的引用。
  • 第三个调用:标准的值传递。
    • 变量var传递进函数func1的时候,函数内部会创建一个叫a的变量,变量拷贝构造自外部的var
  • 第四个调用:也是值传递。
    • 引用ref传递进函数func1的时候,函数内部会创建一个叫a的变量。我们刚提到了,使用引用初始化一个普通变量会调用拷贝构造,拷贝自引用的引用对象。所以这里a的变量是拷贝自引用ref所引用的对象,也就是var
  • 移动一个元素的引用就等同于移动一个元素本身
1
2
3
4
5
6
7
int main(){
    myobj a(200);
    myobj& b = a; //b是a的引用
    myobj c = move(b); //这里移动b和移动a效果相同
    cout << *a.val << endl; //无论访问a还是b都是无效。因为移动b等同于移动a。
    return 0;
}

引用的可能实现

C++ 标准只说明它应该如何表现,而不是它应该如何实现。

一般来说编译器会把引用实现为指针。并采用特殊优化。比如int& i = j。编译器在编译的时候会把所有遇到i的地方替换为j的内存地址。也就是在符号表内添加类似注释的东西通知ij的别名。所以在底层,引用确实可能会被分配内存,也可能不分配。

内置类型使用引用传递不会提高性能

这之所以不能提高性能,是因为在底层实现上,按引用传递还是通过传递参数的地址实现的。地址会被简单编码,这样可以提高从调用者向被调用者传递地址的效率。不过按地址传递可能会使编译器在编译调用者的代码时有一些困惑:被调用者会怎么处理这个地址?理 论上被调用者可以随意更改该地址指向的内容。这样编译器就要假设在这次调用之后,所有缓存在寄存器中的值可能都会变为无效。而重新载入这些变量的值可能会很耗时(可能比拷贝对象的成本高很多)。你或许会问在按 const 引用传递参数时:为什么编译器不能推断出 被调用者不会改变参数的值?不幸的是,确实不能,因为调用者可能会通过它自己的非 const引用修改被引用对象的值(这个解释太好,另一种情况是被调用者可以通过 const_cast 移除参数中的 const)。—-C++模板第二版 7.2.1

继承权限和访问权限

QQ截图20220828052432

默认继承方式

继承的默认类型取决于继承(派生)类型,而不是正在继承的类型(基类)。

  • struct,默认为public
  • class,默认为private
1
2
3
4
5
class A {};
struct B: /* public */ A {}; //派生类型是struct,默认为public

struct A {};
class B: /* private */ A {}; //派生类型是class,默认为private

函数指针和类成员函数指针

1
2
3
4
5
//静态的成员函数指针语法:
void (*ptrStaticFun)() = &ClassName::staticFun;
//成员函数指针语法:
void (ClassName::*ptrNonStaticFun)() = &ClassName::nonStaticFun; //必须加作用域
<返回值类型>(<类名>::*<指针名称>)(<参数列表>) = &<类名>::<非静态成员函数名称>

我们来通过具体例子来说明其中的坑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70

void normal_func(){
    cout << "func" <<endl;
}

void call_back_test(int& a, int& b){
    cout << a + b << endl;
}


class test{
    public:
        int val = 5;
        void class_nonstatic_func(){
            cout << "class_nonstatic_func" <<endl;
        }
        static void class_static_func(){
            cout << "class_static_func" <<endl;
        }
        void callback(void(*ptr)(int&, int&), int& a, int& b){
            ptr(a, b); //普通函数调用
            void(*normal_funcptr)() = &normal_func; //类内获取全局函数指针。注意全局函数必须写在这一行的前面。
            normal_funcptr(); //调用 语法糖
            (*normal_funcptr)(); //调用


            void(test::*class_nonstatic_func_ptr)() = &test::class_nonstatic_func; //类内获取类非静态成员函数指针
            (this->*class_nonstatic_func_ptr)(); //调用。调用类成员函数指针必须加*解引用。而且需要绑定对象。类内就是用this。

            void(*class_static_func_ptr)() = &test::class_static_func; //类内获取类非静态成员函数指针
            class_static_func_ptr();    //调用 语法糖
            (*class_static_func_ptr)(); //调用


        }
};


int main(){
    void (*normal_funcptr)() = &normal_func;          //普通函数指针
    void (*normal_funcptr1)() = normal_func;          //也可以不取地址。语法糖。

    normal_funcptr(); //调用方式,不解引用也可以,语法糖
    normal_funcptr1();

    (*normal_funcptr)();    //解引用,正确方式。应该这么写,更加清晰。
    (*normal_funcptr1)();

    test classobj;
    test* classobj_ptr = new test();
    void (test::*class_nonstatic_func_ptr)() = &test::class_nonstatic_func; //获取类非静态成员函数指针,必须取地址
    //获取类获取类非静态成员函数指针 必须在指针名字前面加类作用域,用来辨别。
    (classobj.*class_nonstatic_func_ptr)(); //对象调用。调用类成员函数指针必须加*解引用。而且需要绑定对象(通过对象调用)。
    (classobj_ptr->*class_nonstatic_func_ptr)(); //指针调用。调用类成员函数指针必须加*解引用。而且需要绑定对象(通过对象调用)



    void(*class_static_func_ptr)() = &test::class_static_func; //获取类静态成员函数指针 ,必须取地址
    //获取类获取类静态成员函数指针 不需要指针名字前面加类作用域。因为静态成员函数可以理解为在类作用域内的全局函数。
    //所以取地址还要加作用域,函数名字不需要。
    class_static_func_ptr(); //直接调用。语法糖
    (*class_static_func_ptr)(); //直接调用。解引用

    //!(classobj.*class_static_func_ptr)();     //对象调用错误
    //!(classobj_ptr->*class_static_func_ptr)();//指针调用错误


    return 0;
}
  • 声明
    • 普通函数指针
      • 直接取名字即可
    • 类成员函数指针
      • 必须在指针名字前面加类作用域,用来辨别。
    • 类静态成员函数指针
      • 和普通函数指针一样,不需要加作用域。因为静态成员函数可以理解为在类作用域内的全局函数。(所以赋值依旧需要取地址+作用域)
  • 赋值
    • 普通函数指针
      • 可以直接用函数名赋值。语法糖。
      • 也可以取地址
    • 类成员函数指针和类静态成员函数指针
      • 赋值必须要加作用域解析运算符并且取地址
  • 调用
    • 普通函数指针
      • 直接调用。语法糖
      • 解引用调用。注意解引用必须加括号。
    • 类成员函数指针
      • 不可以直接调用,必须解引用。同时必须绑定对象使用对象调用或对象指针调用
      • 类内必须使用this用来充当绑定对象。
    • 类静态成员函数指针
      • 和普通函数指针一样。
      • 直接调用。语法糖
      • 解引用调用。注意解引用必须加括号。

成员函数模板 函数指针和类模板 成员函数指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
template<typename T> //类模板
class myclass{
    public:
    void func(){
        cout << "class func" << endl;
    }
    int func2(int a){
        cout << "class func2" << endl;
        return a;
    }
};

class nclass{
    public:
        template <typename T>
        void func(T a){
            cout <<"nclass func" << endl;
            cout << a << endl;
        }
        template <typename RT, typename T> //函数模板
        RT func2(T a){
            cout <<"nclass func" << endl;
            cout << a << endl;
            return a;
        }
};
  • 由于模板本身没有地址(模板中详细介绍过),所以必须要依托其实例化后的东西才可以。所以需要显式指明模板参数。
  • 类模板成员函数指针
    • 左侧指针声明的时候,不仅要依照类成员函数指针语法,同时要显式指明类模板参数。
    • 右侧进行取地址的时候,也必须要指定类模板参数。因为不指定参数不会引发实例化,无法取地址。
1
2
3
4
5
6
7
8
myclass<int> myclassobj; //实例化的对象

void(myclass<int>::*myptr)() = &myclass<int>::func; 
(myclassobj.*myptr)();

int(myclass<int>::*myptr2)(int) = &myclass<int>::func2;
(myclassobj.*myptr2)(4);

  • 成员函数模板函数指针
    • 左侧指针声明的时候,由于此时不涉及类模板,则语法和普通类成员函数指针一致
    • 右侧取地址的时候,由于函数模板可以自动推导类型。所以不需要显式指明模板参数。
1
2
3
4
5
6
7
8
9
10
nclass nclassobj; //实例化的对象

void(nclass::*nptr)(int) = &nclass::func;
(nclassobj.*nptr)(234);

float(nclass::*nptr2)(int) = &nclass::func2;
(nclassobj.*nptr2)(2343);

float(nclass::*nptr_noneed)(int) = &nclass::func2<float, int>; //不是必须显式指明模板参数。
(nclassobj.*nptr_noneed)(2343);

类成员(变量)指针

  • 类成员变量指针与普通意义上的指针不一样。存放的是类内偏移量

  • 指向成员变量的指针并不指向某一个具体对象的内存地址。它指向的是一个类的特定成员,而不是指定某一个特定对象的成员。
  • 指向类成员变量的指针”的实现方式是获取该类成员在类中的偏移值,这也同样印证了指向类成员变量的指针不可以单独访问 (依赖于某一特定对象) 的原因 —— 它只是偏移值,需要通过特定的对象来访问该对象此偏移值处的子对象。
  • 在我们对一个 “指向类内数据成员的指针” 赋予初始值,实际上是获得了该数据成员在类内的偏移量。除非是对一个类内的 static 数据成员进行 & 操作,否则不会带来一个实际的地址,而是一个偏移量。
1
2
3
4
5
6
//静态的成员变量指针语法:
int *ptrStaticitem = &ClassName::Staticitem
<成员变量数据类型>*<指针名> = &<类名>::<静态数据成员名称>
//成员变量指针语法:
int ClassName::*ptrNonStaticitem = &ClassName::nonStaticitem
<成员变量数据类型><类名>::*<指针名> = &<类名>::<非静态数据成员名称>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class person{
    public:
        int id = 20;
        static int info;
};
int person::info = 10;

int main()
{
    person human;
    person* human_ptr = new person();
    int person::*nonstatic_ptr = &person::id; //非静态成员变量指针
    cout << human.*nonstatic_ptr << endl; //必须对指针解引用后通过对象或指针访问
    cout << human_ptr->*nonstatic_ptr << endl; //指针解引用访问。

    int* static_ptr = &person::info; //静态成员变量指针
    cout << *static_ptr << endl; //解引用
    cout << person::info << endl;//直接通过作用域运算符访问类静态成员变量。
    return 0;
}
  • 非静态成员变量:
    • 和非静态成员函数相似。不可以直接使用,必须解引用后绑定对象使用对象调用或对象指针调用
  • 静态成员变量:
    • 和静态成员函数相似。可以直接使用,不必绑定对象。

https://imzlp.com/posts/27615/

类成员(变量)指针和类成员函数指针的具体区分方式在模板的结尾。但是记住一点,类成员函数指针是类成员(变量)指针的子集。

https://zhuanlan.zhihu.com/p/659510753

为构造函数提供默认值

两种方式。第一种是用构造函数初始化列的方式:

1
2
3
4
5
6
7
8
9
class test{
    public:
        test(int x = 5, int y = 10):_x(x), _y(y){};
        int _x, _y;
};
int main(){
    test t;
    cout << t._x << endl;
}

第二种是常规方式

1
2
3
4
5
6
7
8
9
10
11
12
class test{
    public:
        test(int x = 5, int y = 10){
            _x = x; 
            _y = y;
        }
        int _x, _y;
};
int main(){
    test t;
    cout << t._x << endl;
}

指针名称解读

顺序系列

  • int* p[10] 意思是p是一个大小为10的储存int*类型指针的数组 。
    • 它是一个指针数组
  • int (*p)[10]意思是有一个指针p指向一个大小为10的储存int类型的数组。
    • 它是一个指向数组的指针
  • int* p(int) 是一个名为p的函数,这个函数返回int*,入参是int
    • 它是一个函数
  • int (*p)(int)是一个名为p的函数指针,这个函数指针指向的函数返回值为int,入参为int
    • 它是一个函数指针

const系列 const关键字总是优先作用于左侧的东西,除非左侧没东西。

  • const int *ptr; = int const *ptr; = const int* ptr; = int const* ptr
    • *念成指向, 这句话翻译过来就是:指针ptr指向const int
    • 这是一个常量指针。
  • int* const ptr
    • *念成指向 这句话翻译过来就是:const指针ptr 指向int
  • ``const int* const ptr`
    • *念成指向 这句话翻译过来就是:const指针ptr指向const int

指针计算

指针A给指针B赋值的意思是将指针A指向的地址赋给指针B

1
2
3
4
5
6
7
8
9
10
int digit = 5;
int* a = &digit;
int* b = a; //指针a指向的地址赋给指针b 没有新对象产生所以不会调用拷贝构造。
int* c = &*a; //等同于将指针a先解引用(*a = 5),得到指针a指向的的值。然后把这个值取地址(&*a)赋值给指针c。

cout << b << endl; //打印b储存的地址。也就是b指向的地址。 等同于&digit和*&b
cout << *b << endl; //解引用b 打印b指向的地址的值。
cout << &b << endl; //打印b自己的地址。
cout << &digit << endl; //打印变量地址。

再来个例子:

1
2
3
4
5
6
7
8
int a = 5;
int* ptr = &a;

cout << &a <<endl;      //打印a的地址。0x61fe1c
cout <<ptr << endl;     //打印指针ptr储存的值,也就是a的地址。0x61fe1c
cout <<&ptr << endl;    //打印指针ptr自己的地址。0x61fe10
cout <<*&ptr << endl;   //打印指针ptr储存的值。也就是a的地址。0x61fe1c
// *&ptr = ptr = &a;

QQ截图20220905085526

指针加法

指针直接和数字相加:意思是指针当前指向的地址 + 对应指针类型的大小 * 数字。

举例:

1
2
3
4
5
int * p;
p = (int*)malloc(100); //分配100字节。
cout << p<< endl; //打印p储存的地址。也就是指针指向的地址。是内存首地址。此处输出 0x771410
auto y = p + 1; //把p指向的地址偏移4位(因为int是4字节)后赋值给y。
printf("%#x",y); //打印y储存的地址。也就是指向的地址。此处输出 0x771414

也就是说指针直接和数字相加,可以理解为用指针操作数组。加几就是将指针往后移动几位。具体偏移量也就是对应对象的大小*移动位数。

如果想要指定一个具体的偏移量应该怎么办?

首先,可以使用转型将指针转换为对应类型。然后再加数字。

举例:

1
2
3
4
5
6
7
obj* p;
p = (obj*)malloc(sizeof(int) * 10);
cout << p<< endl; //打印p储存的地址。也就是指针指向的地址。是内存首地址。 此处输出 0x771410
auto x = (char*)p + 1; //把p指向的地址偏移1位后赋值给x。因为char是1位。
printf("%#x\n",x); //打印x储存的地址。也就是指向的地址。此处输出 0x771411
auto y = (obj*)p + 1; //把p指向的地址偏移8位后赋值给x。因为obj是指针类型。8位。
printf("%#x\n",y); //打印y储存的地址。也就是指向的地址。此处输出 0x771418

注意:指针类型转换指的是指针转换为对应类型的指针。

此处不要使用cout

auto x = (char*)p的意思是把指针p转为char*类型的指针。不可以auto x = (char)p。 这样做的意思是直接转换成了char。当然,所有的指针都是8字节。记住,指针类型指的是告诉指针应该读取指针指向的地址之后多大的数据。

怎么理解这个操作呢。我们可以想一下。如果 aint*类型,也就是int类型指针。意思是指针a指向的数据是int,所以需要以4字节为一段进行读取。也就是一次读取四个字节。那么a+1其实是对地址进行了+4操作。因为指针+1的意思是将指针往后移动一个对象位。也就是指针当前指向的地址 + 对应指针类型的大小(此处为4) * 数量(此处为1)。但是我们如果想要对地址+1的话怎么办?我们可以把指针a转换为char*类型也就是char类型指针。而不是int类型指针。这样相当于告诉编译器,指针a指向的数据是char。所以需要以1字节为一段进行读取。也就是一次读取一个字节。所以此时a+1就是对地址进行了+1操作。也就是指针当前指向的地址 + 对应指针类型的大小(此处为1) * 数量(此处为1)。

移动一个对象位也就是移动的偏移量为对象大小。

1
2
3
4
5
int digit = 5;
int* a = &digit;
cout << a << endl;
auto x = (char*)a + 1;
printf("%#x\n",x);

注意!(char*)a + 1往后移动1位的时候,(char*)a的意思是把指针a看成指向char类型的指针(告诉编译器一段是1)。所以(char*)a + 1不是+2,还是+1。因为指针a指向的地址根本没变。仅仅是看成了char类型的指针后+1

成员函数的引用限定符

假如有些情况下,我们想限制某些成员函数只能被左值对象调用,或者只能被右值对象调用,C++11中引入了引用限定符:

  • 在函数声明后面加上&符号,表示该方法只能被左值对象调用
  • 在函数声明后面加上&&符号,表示该方法只能被右值对象调用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class myobj{
    public:
        // void func(int a, int b){
        //     cout << "normal func" << endl;
        // }
        void func(int a, int b) &{
            cout << "lref func" << endl;
        }
        void func(int a, int b) &&{
            cout << "rref func" << endl;
        }
};

int main(){
    int a = 10;
    int b = 100;
    myobj obj;
    obj.func(a, b); //左值对象 输出 lref func
    move(obj).func(a, b); //通过move把左值变为右值,此时是右值对象 输出rref func
    return 0;
}
  • 注意事项:

    • 引用限定符只能用在成员函数上,而且不能用于static函数。(this指针原因)
    • 带有一个&和不带&的函数只能保留一个。因为他们的含义有二义性。
    • const搭配使用的时候,const限定符必须写在引用限定符之前
    1
    2
    3
    4
    5
    6
    
    void func(int a, int b) const &{ //可以
        cout << "lref func" << endl;
    }
    void func(int a, int b) & const{ //不可以
        cout << "lref func" << endl;
    }
    

引用限定符和const搭配的注意事项

1
2
3
4
5
6
7
8
9
10
11
12
13
struct test{
    void f() const &{ // 注意const
        std::cout << "lvalue" << std::endl;
    }
    void f() &&{
        std::cout << "rvalue" << std::endl;
    }
};
int main() {
    test a;
    a.f(); // lvalue
    test{}.f(); // rvalue
}

一切正常。如果我们去掉右值版本的呢?

1
2
3
4
5
6
7
8
9
10
11
struct test{
    void f() const &{ // 注意const
        std::cout << "lvalue" << std::endl;
    }

};
int main() {
    test a;
    a.f(); // lvalue
    test{}.f(); // lvalue
}

可以通过编译,我们发现临时对象调用的也输出lvalue。

但是如果我们去掉const且去掉右值版本的呢

1
2
3
4
5
6
7
8
9
10
11
12
13
struct test{
    void f() &{ //无const
        std::cout << "lvalue" << std::endl;
    }
    // void f() &&{
    //     std::cout << "rvalue" << std::endl;
    // }
};
int main() {
    test a;
    a.f();
    test{}.f(); // 错误
}

因为我们需要把后面的const和引用限定符的组合放在一起看。这一组修饰是限定了this指针,和const一样。

因为const&可以接一切,所以此时临时对象也可以调用这个const&修饰的版本。如果不加const,则不行。

关于const函数重载。修饰(成员)函数的const算作函数签名的一部分

const修饰函数参数一般不作为函数签名的一部分。除非修饰的参数是指针或引用。并且需要是修饰的是底层。

  • const修饰的函数参数是值的时候不可以进行重载。如下代码编译错误。
1
2
3
4
5
6
7
8
9
10
11
class myobj{
    public: 
        myobj(int x):cal(x){}
        int cal;
};
void func1(myobj a){
        cout << "non const" << endl;
        }
void func1(const myobj  a){
    cout <<"const" << endl;
}
  • const修饰的函数参数是指针或引用的时候,并且修饰的是底层的时候可以进行重载。

  • volatile也适用于此规则。

  • 以上例外const必须修饰的是指针指向的内容, 即ponter-to-const;
    • 如果是指针本身是const的,则不能重载
  • 原因是函数签名不包括形参列表参数的顶层CV限定
    • 顶层const表示指针本身是个常量。顶层修饰的是对象本身。
      • myobj* const b = new myobj(20); const指针b指向myobj
    • 底层const表示指针所指向的对象是个常量。
      • const myobj* b = new myobj(20);指针b指向const myobj
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
//指针
void func(myobj* a){
        cout << "non const" << endl;
        }
void func(const myobj* a){
    cout <<"const" << endl;
}
//引用
void func1(myobj& a){
        cout << "non const" << endl;
        }
void func1(const myobj& a){
    cout <<"const" << endl;
}


int main(){
    myobj* a = new myobj(10);
    const myobj* b = new myobj(20);
    func(a); //输出non const
    func(b); //输出const


    myobj obj(10);
    myobj& refa = obj;
    const myobj& refb = obj;

    func1(refa); //输出 non const
    func1(refb); //输出 const


    return 0;

}
  • const引用可以绑定到非const对象,但是非const引用不可绑定const对象。因为不能保证其不被修改。
1
2
3
4
5
6
string t = "234"; //非const对象
const string& y = t; //可以 const引用

const string s = "234"; //const对象
string& b = s; //不可以 非const引用

  • 所以函数入参为引用的时候,非const对象可以传入const引用参数。也就是const引用绑定到非const对象。比如作用是防止修改。但是const对象不可传入非const引用参数。也就是非const引用不可以绑定到const对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void test(const string& a){
    cout << a << endl;
}

void test2(string& a){
    cout << a << endl;
}
int main(){
    const string str1 = "234";
    string str2 = "234";
    test(str1);
    test2(str1); //不可以。const对象不能传入非const参数
    test(str2);
    test2(str2);

}
  • 值传递不受影响

const如果放在函数后面(修饰成员函数)只能放在类成员函数后面

先看代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class myclass{
public:
    int vala;
    int valb;
    myclass(int x, int y):vala(x), valb(y){}

    void changethis(myclass& obj)const {
        cout <<"const" << endl;
    }
    // void changethis(const myclass* const this, myclass& obj) const{ 
    //     cout <<"non const" << endl;
    // }
    void changethis(myclass& obj) {
        cout <<"non const" << endl;
    }
    // void changethis(myclass* const this, myclass& obj) {
    //     cout <<"non const" << endl;
    // }
};

int main(){
    myclass obj(10,20);
    const myclass obj1(10,20);
    obj.changethis(obj); //调用不带const的
    obj1.changethis(obj); //调用带const的
    return 0;
}

const放在类成员函数后面的意义是,不可以修改当前调用对象的数据成员的值。切记注意,入参是否可以被修改是不归函数管的。函数加const只是负责禁止修改调用方。在这里也就是禁止修改调用方obj1的值。

const修饰成员函数修饰的是什么?是this指针

我们在上面代码段看到了两段被注释掉的函数,里面写出了如果显式this指针会是什么样。

  • 注意,由于this指针不能改变指向,所以this指针是一个常量指针。也就是myclass* const this

  • 如果我们不想修改一个参数的值,办法就是让这个参数被const修饰。在这里就是把this指针也用const修饰。则会变成const myclass* const this。这也说明了为什么const只能修饰成员函数,因为非成员函数没有this指针。

  • 又由于this指针会被编译器根据对象来隐式传入。所以const对象的this指针必然是也是常量指针。所以自然const对象无法调用非const函数。因为this指针的不匹配。const可以被隐式(函数调用)加上但是不能被去除。也就是权限的缩紧是可以的,放松是不可以的。

  • 简单理解就是因为const成员无法进行修改。如果const成员变量调用了非const函数,函数不能保证不修改const成员,如果修改了会报错,所以直接加以限制。当然了非const成员因为可以修改也可以不修改,都不会报错。所以自然可以调用const函数也可以调用非const函数。

  • const成员函数不能调用非const成员函数。因为在const成员函数里面的this指针被const修饰,在const成员函数里面调用非const成员函数时,相当于将const修饰的this指针传给非const成员函数。
    • 也可以理解为权限的扩张是禁止的。不可以修改(整体)-> 可以修改(局部)是不可以的。
  • const成员函数可以调用const成员函数的原因是:权限的缩紧是可以的。
    • 也就是:可以修改(整体)->不可以修改(局部)这样是可以的

无意间发生的const与非const冲突

1
2
3
4
5
6
7
8
9
10
11
12
class myclass{
public:
    int vala;
    int valb;
    myclass(int x, int y):vala(x), valb(y){}
    void func(){
		//一些内容
    }
    void changethis(const myclass& obj){
        obj.func(); //错误。const对象(引用)不能调用非const函数
    }
};
  • 我们看到了。经常性的我们会使用常量左值引用做为函数入参签名,一是防止拷贝二是防止修改。但是我们可能需要通过这个对象调用其内部的一些函数。此时就不可以通过const引用调用非const函数

注意 this指针并不是对象本身的一部分。

注意 this指针并不是对象本身的一部分。也不会影响sizeof的结果。它的作用域是在类的内部。this指针只不过会在调用非静态函数的时候被编译器隐式加入。

unknown

关于const函数重载。const算作函数签名的一部分

我们在上面代码可以看到,我们有一个函数重载。因为const算作函数签名的一部分。

但是编译器如何判断我们调用的是哪个函数呢?虽然表面参数一样,但其实隐藏的this形参是不同的。

  • 所以如果我们调用方是const对象,则会调用const版本的函数。如果是非const对象就调用非const版本的函数。

unknown (1)

为什么返回值类型不能做为函数重载的依据?也就是为什么仅返回值不同的函数不可重载?

  • 理论上来说就是函数的返回值不是普通函数签名的一部分。
  • 实际上来说是有二义性。比如
1
2
3
4
5
6
7
int func();
string func();

int main(){
    func(); //调用哪个func?
    return 0;
}

如上面代码,调用func的时候编译器无法确定到底调用哪个func

虽然返回值不是普通函数签名一部分,但是在链接期,匹配的不仅是函数的签名,还包括其类型。所以返回值依旧要匹配(个人理解)

函数签名总结

  • 普通函数的函数签名包括:
    • 函数名(非限定名)或者 生成该函数的函数模板名称
    • 参数类型和个数(参数列表)。如果函数是从函数模板中生成的,那么指的是替换前的模板参数
    • 函数名称所属的类或命名空间作用域;如果函数名称拥有内部链接,还包括该名称声明所在的编译单元。
    • 如果是类成员函数则还包括函数的:
      • const限定符
      • volatile限定符
      • ref限定符
  • 函数模板的签名除了上面的以外还包括:
    • 返回值类型
    • 模板参数和模板实参
    • 格外注意,函数模板的签名包括返回值类型导致的表面说“函数模板的重载“其实是一种重载决议。被不同类型实例化的函数模板是不同的函数。
  • 函数参数的const一般不算做函数签名。除非该参数为引用或指针
    • 普通参数的const不算做函数签名是为了兼容C。
    • C语言并不支持函数重载,因此编译C语言代码的函数时不会带上函数的参数类型,一般只包括函数名。

普通函数的函数签名不包括返回值类型。也就是无法仅通过返回值类型的不同进行函数重载

关键字 final

  • final关键字的意义是阻止子类重写自身(父类)的虚函数或禁止继承
  • 一般不用在父类,因为没有意义。
  • 一般用在某个子类防止子类的子类重写子类的虚函数 或者是 禁止继承。
本文由作者按照 CC BY 4.0 进行授权