《逆袭进大厂》之C++篇49问49答(绝对的干货)二

简介: 笔记

33、为什么析构函数一般写成虚函数


由于类的多态性,基类指针可以指向派生类的对象,如果删除该基类的指针,就会调用该指针指向的派生类析构函数,而派生类的析构函数又自动调用基类的析构函数,这样整个派生类的对象完全被释放。如果析构函数不被声明成虚函数,则编译器实施静态绑定,在删除基类指针时,只会调用基类的析构函数而不调用派生类析构函数,这样就会造成派生类对象析构不完全,造成内存泄漏。所以将析构函数声明为虚函数是十分必要的。在实现多态时,当用基类操作派生类,在析构时防止只析构基类而不析构派生类的状况发生,要将基类的析构函数声明为虚函数。举个例子:

#include <iostream>
using namespace std;
class Parent{
public:
    Parent(){
        cout << "Parent construct function"  << endl;
    };
    ~Parent(){
        cout << "Parent destructor function" <<endl;
    }
};
class Son : public Parent{
public:
    Son(){
        cout << "Son construct function"  << endl;
    };
    ~Son(){
        cout << "Son destructor function" <<endl;
    }
};
int main()
{
    Parent* p = new Son();
    delete p;
    p = NULL;
    return 0;
}
//运行结果:
//Parent construct function
//Son construct function
//Parent destructor function

将基类的析构函数声明为虚函数:

#include <iostream>
using namespace std;
class Parent{
public:
    Parent(){
        cout << "Parent construct function"  << endl;
    };
    virtual ~Parent(){
        cout << "Parent destructor function" <<endl;
    }
};
class Son : public Parent{
public:
    Son(){
        cout << "Son construct function"  << endl;
    };
    ~Son(){
        cout << "Son destructor function" <<endl;
    }
};
int main()
{
    Parent* p = new Son();
    delete p;
    p = NULL;
    return 0;
}
//运行结果:
//Parent construct function
//Son construct function
//Son destructor function
//Parent destructor function

34、构造函数能否声明为虚函数或者纯虚函数,析构函数呢?


析构函数

  • 析构函数可以为虚函数,并且一般情况下基类析构函数要定义为虚函数。
  • 只有在基类析构函数定义为虚函数时,调用操作符delete销毁指向对象的基类指针时,才能准确调用派生类的析构函数(从该级向上按序调用虚函数),才能准确销毁数据。
  • 析构函数可以是纯虚函数,含有纯虚函数的类是抽象类,此时不能被实例化。但派生类中可以根据自身需求重新改写基类中的纯虚函数。

构造函数

  • 构造函数不能定义为虚函数。在构造函数中可以调用虚函数,不过此时调用的是正在构造的类中的虚函数,而不是子类的虚函数,因为此时子类尚未构造好。


35、C++中的重载、重写(覆盖)和隐藏的区别


(1)重载(overload)

重载是指在同一范围定义中的同名成员函数才存在重载关系。主要特点是函数名相同,参数类型和数目有所不同,不能出现参数个数和类型均相同,仅仅依靠返回值不同来区分的函数。重载和函数成员是否是虚函数无关。举个例子:

class A{
    ...
    virtual int fun();
    void fun(int);
    void fun(double, double);
    static int fun(char);
    ...
}

(2)重写(覆盖)(override)

重写指的是在派生类中覆盖基类中的同名函数,重写就是重写函数体要求基类函数必须是虚函数且:

  • 与基类的虚函数有相同的参数个数
  • 与基类的虚函数有相同的参数类型
  • 与基类的虚函数有相同的返回值类型

举个例子:

//父类
class A{
public:
    virtual int fun(int a){}
}
//子类
class B : public A{
public:
    //重写,一般加override可以确保是重写父类的函数
    virtual int fun(int a) override{}
}

重载与重写的区别:

  • 重写是父类和子类之间的垂直关系,重载是不同函数之间的水平关系
  • 重写要求参数列表相同,重载则要求参数列表不同,返回值不要求
  • 重写关系中,调用方法根据对象类型决定,重载根据调用时实参表与形参表的对应关系来选择函数体

(3)隐藏(hide)

隐藏指的是某些情况下,派生类中的函数屏蔽了基类中的同名函数,包括以下情况:

  • 两个函数参数相同,但是基类函数不是虚函数。和重写的区别在于基类函数是否是虚函数。举个例子:
//父类
class A{
public:
    void fun(int a){
        cout << "A中的fun函数" << endl;
    }
};
//子类
class B : public A{
public:
    //隐藏父类的fun函数
    void fun(int a){
        cout << "B中的fun函数" << endl;
    }
};
int main(){
    B b;
    b.fun(2); //调用的是B中的fun函数
    b.A::fun(2); //调用A中fun函数
    return 0;
}
  • 两个函数参数不同,无论基类函数是不是虚函数,都会被隐藏。和重载的区别在于两个函数不在同一个类中。举个例子:
//父类
class A{
public:
    virtual void fun(int a){
        cout << "A中的fun函数" << endl;
    }
};
//子类
class B : public A{
public:
    //隐藏父类的fun函数
   virtual void fun(char* a){
       cout << "A中的fun函数" << endl;
   }
};
int main(){
    B b;
    b.fun(2); //报错,调用的是B中的fun函数,参数类型不对
    b.A::fun(2); //调用A中fun函数
    return 0;
}


36、C++的多态如何实现


C++的多态性,一言以蔽之就是:

在基类的函数前加上virtual关键字,在派生类中重写该函数,运行时将会根据所指对象的实际类型来调用相应的函数,如果对象类型是派生类,就调用派生类的函数,如果对象类型是基类,就调用基类的函数。

举个例子:

#include <iostream>
using namespace std;
class Base{
public:
    virtual void fun(){
        cout << " Base::func()" <<endl;
    }
};
class Son1 : public Base{
public:
    virtual void fun() override{
        cout << " Son1::func()" <<endl;
    }
};
class Son2 : public Base{
};
int main()
{
    Base* base = new Son1;
    base->fun();
    base = new Son2;
    base->fun();
    delete base;
    base = NULL;
    return 0;
}
// 运行结果
// Son1::func()
// Base::func()

例子中,Base为基类,其中的函数为虚函数。子类1继承并重写了基类的函数,子类2继承基类但没有重写基类的函数,从结果分析子类体现了多态性,那么为什么会出现多态性,其底层的原理是什么?这里需要引出虚表和虚基表指针的概念。

虚表:虚函数表的缩写,类中含有virtual关键字修饰的方法时,编译器会自动生成虚表

虚表指针:在含有虚函数的类实例化对象时,对象地址的前四个字节存储的指向虚表的指针

上图中展示了虚表和虚表指针在基类对象和派生类对象中的模型下面阐述实现多态的过程

(1)编译器在发现基类中有虚函数时,会自动为每个含有虚函数的类生成一份虚表,该表是一个一维数组,虚表里保存了虚函数的入口地址

(2)编译器会在每个对象的前四个字节中保存一个虚表指针,即vptr,指向对象所属类的虚表。在构造时,根据对象的类型去初始化虚指针vptr,从而让vptr指向正确的虚表,从而在调用虚函数时,能找到正确的函数

(3)所谓的合适时机,在派生类定义对象时,程序运行会自动调用构造函数,在构造函数中创建虚表并对虚表初始化。在构造子类对象时,会先调用父类的构造函数,此时,编译器只“看到了”父类,并为父类对象初始化虚表指针,令它指向父类的虚表;当调用子类的构造函数时,为子类对象初始化虚表指针,令它指向子类的虚表

(4)当派生类对基类的虚函数没有重写时,派生类的虚表指针指向的是基类的虚表;当派生类对基类的虚函数重写时,派生类的虚表指针指向的是自身的虚表;当派生类中有自己的虚函数时,在自己的虚表中将此虚函数地址添加在后面

这样指向派生类的基类指针在运行时,就可以根据派生类对虚函数重写情况动态的进行调用,从而实现多态性。

《C++实现多态的原理》:https://blog.csdn.net/qq_37954088/article/details/79947898


37、C++有哪几种的构造函数


C++中的构造函数可以分为4类:

  • 默认构造函数
  • 初始化构造函数(有参数)
  • 拷贝构造函数
  • 移动构造函数(move和右值引用)
  • 委托构造函数
  • 转换构造函数

举个例子:

#include <iostream>
using namespace std;
class Student{
public:
    Student(){//默认构造函数,没有参数
        this->age = 20;
        this->num = 1000;
    };  
    Student(int a, int n):age(a), num(n){}; //初始化构造函数,有参数和参数列表
    Student(const Student& s){//拷贝构造函数,这里与编译器生成的一致
        this->age = s.age;
        this->num = s.num;
    }; 
    Student(int r){   //转换构造函数,形参是其他类型变量,且只有一个形参
        this->age = r;
        this->num = 1002;
    };
    ~Student(){}
public:
    int age;
    int num;
};
int main(){
    Student s1;
    Student s2(18,1001);
    int a = 10;
    Student s3(a);
    Student s4(s3);
    printf("s1 age:%d, num:%d\n", s1.age, s1.num);
    printf("s2 age:%d, num:%d\n", s2.age, s2.num);
    printf("s3 age:%d, num:%d\n", s3.age, s3.num);
    printf("s2 age:%d, num:%d\n", s4.age, s4.num);
    return 0;
}
//运行结果
//s1 age:20, num:1000
//s2 age:18, num:1001
//s3 age:10, num:1002
//s2 age:10, num:1002
  • 默认构造函数和初始化构造函数在定义类的对象,完成对象的初始化工作
  • 复制构造函数用于复制本类的对象
  • 转换构造函数用于将其他类型的变量,隐式转换为本类对象

《浅谈C++中的几种构造函数》:https://blog.csdn.net/zxc024000/article/details/51153743


38、浅拷贝和深拷贝的区别


浅拷贝

浅拷贝只是拷贝一个指针,并没有新开辟一个地址,拷贝的指针和原来的指针指向同一块地址,如果原来的指针所指向的资源释放了,那么再释放浅拷贝的指针的资源就会出现错误。

深拷贝

深拷贝不仅拷贝值,还开辟出一块新的空间用来存放新的值,即使原先的对象被析构掉,释放内存了也不会影响到深拷贝得到的值。在自己实现拷贝赋值的时候,如果有指针变量的话是需要自己实现深拷贝的。

#include <iostream>  
#include <string.h>
using namespace std;
class Student
{
private:
    int num;
    char *name;
public:
    Student(){
        name = new char(20);
        cout << "Student" << endl;
    };
    ~Student(){
        cout << "~Student " << &name << endl;
        delete name;
        name = NULL;
    };
    Student(const Student &s){//拷贝构造函数
        //浅拷贝,当对象的name和传入对象的name指向相同的地址
        name = s.name;
        //深拷贝
        //name = new char(20);
        //memcpy(name, s.name, strlen(s.name));
        cout << "copy Student" << endl;
    };
};
int main()
{
    {// 花括号让s1和s2变成局部对象,方便测试
        Student s1;
        Student s2(s1);// 复制对象
    }
    system("pause");
    return 0;
}
//浅拷贝执行结果:
//Student
//copy Student
//~Student 0x7fffed0c3ec0
//~Student 0x7fffed0c3ed0
//*** Error in `/tmp/815453382/a.out': double free or corruption (fasttop): 0x0000000001c82c20 ***
//深拷贝执行结果:
//Student
//copy Student
//~Student 0x7fffebca9fb0
//~Student 0x7fffebca9fc0

从执行结果可以看出,浅拷贝在对象的拷贝创建时存在风险,即被拷贝的对象析构释放资源之后,拷贝对象析构时会再次释放一个已经释放的资源,深拷贝的结果是两个对象之间没有任何关系,各自成员地址不同。

《C++面试题之浅拷贝和深拷贝的区别》:https://blog.csdn.net/caoshangpa/article/details/79226270


39、内联函数和宏定义的区别


内联(inline)函数和普通函数相比可以加快程序运行的速度,因为不需要中断调用,在编译的时候内联函数可以直接嵌入到目标代码中。

内联函数适用场景

  • 使用宏定义的地方都可以使用inline函数
  • 作为类成员接口函数来读写类的私有成员或者保护成员,会提高效率

为什么不能把所有的函数写成内联函数

内联函数以代码复杂为代价,它以省去函数调用的开销来提高执行效率。所以一方面如果内联函数体内代码执行时间相比函数调用开销较大,则没有太大的意义;另一方面每一处内联函数的调用都要复制代码,消耗更多的内存空间,因此以下情况不宜使用内联函数:

  • 函数体内的代码比较长,将导致内存消耗代价
  • 函数体内有循环,函数执行时间要比函数调用开销大

主要区别

  • 内联函数在编译时展开,宏在预编译时展开
  • 内联函数直接嵌入到目标代码中,宏是简单的做文本替换
  • 内联函数有类型检测、语法判断等功能,而宏没有
  • 内联函数是函数,宏不是
  • 宏定义时要注意书写(参数要括起来)否则容易出现歧义,内联函数不会产生歧义
  • 内联函数代码是被放到符号表中,使用时像宏一样展开,没有调用的开销,效率很高;

《inline函数和宏定义区别 整理》:https://blog.csdn.net/wangliang888888/article/details/77990650

  • 在使用时,宏只做简单字符串替换(编译前)。而内联函数可以进行参数类型检查(编译时),且具有返回值。
  • 内联函数本身是函数,强调函数特性,具有重载等功能。
  • 内联函数可以作为某个类的成员函数,这样可以使用类的保护成员和私有成员,进而提升效率。而当一个表达式涉及到类保护成员或私有成员时,宏就不能实现了。


40、构造函数、析构函数、虚函数可否声明为内联函数


首先,将这些函数声明为内联函数,在语法上没有错误。因为inline同register一样,只是个建议,编译器并不一定真正的内联。

register关键字:这个关键字请求编译器尽可能的将变量存在CPU内部寄存器中,而不是通过内存寻址访问,以提高效率

举个例子:

#include <iostream>
using namespace std;
class A
{
public:
    inline A() {
        cout << "inline construct()" <<endl;
    }
    inline ~A() {
        cout << "inline destruct()" <<endl;
    }
    inline virtual void  virtualFun() {
        cout << "inline virtual function" <<endl;
    }
};
int main()
{
    A a;
    a.virtualFun();
    return 0;
}
//输出结果
//inline construct()
//inline virtual function
//inline destruct()

构造函数和析构函数声明为内联函数是没有意义的

《Effective C++》中所阐述的是:将构造函数和析构函数声明为inline是没有什么意义的,即编译器并不真正对声明为inline的构造和析构函数进行内联操作,因为编译器会在构造和析构函数中添加额外的操作(申请/释放内存,构造/析构对象等),致使构造函数/析构函数并不像看上去的那么精简。其次,class中的函数默认是inline型的,编译器也只是有选择性的inline,将构造函数和析构函数声明为内联函数是没有什么意义的。

将虚函数声明为inline,要分情况讨论

有的人认为虚函数被声明为inline,但是编译器并没有对其内联,他们给出的理由是inline是编译期决定的,而虚函数是运行期决定的,即在不知道将要调用哪个函数的情况下,如何将函数内联呢?

上述观点看似正确,其实不然,如果虚函数在编译器就能够决定将要调用哪个函数时,就能够内联,那么什么情况下编译器可以确定要调用哪个函数呢,答案是当用对象调用虚函数(此时不具有多态性)时,就内联展开

综上,当是指向派生类的指针(多态性)调用声明为inline的虚函数时,不会内联展开;当是对象本身调用虚函数时,会内联展开,当然前提依然是函数并不复杂的情况下

《构造函数、析构函数、虚函数可否内联,有何意义》:https://www.cnblogs.com/helloweworld/archive/2013/06/14/3136705.html


41、auto、decltype和decltype(auto)的用法


(1)auto

C++11新标准引入了auto类型说明符,用它就能让编译器替我们去分析表达式所属的类型。和原来那些只对应某种特定的类型说明符(例如 int)不同,

auto 让编译器通过初始值来进行类型推演。从而获得定义变量的类型,所以说 auto 定义的变量必须有初始值。举个例子:

//普通;类型
int a = 1, b = 3;
auto c = a + b;// c为int型
//const类型
const int i = 5;
auto j = i; // 变量i是顶层const, 会被忽略, 所以j的类型是int
auto k = &i; // 变量i是一个常量, 对常量取地址是一种底层const, 所以b的类型是const int*
const auto l = i; //如果希望推断出的类型是顶层const的, 那么就需要在auto前面加上cosnt
//引用和指针类型
int x = 2;
int& y = x;
auto z = y; //z是int型不是int& 型
auto& p1 = y; //p1是int&型
auto p2 = &x; //p2是指针类型int*

(2)decltype

有的时候我们还会遇到这种情况,我们希望从表达式中推断出要定义变量的类型,但却不想用表达式的值去初始化变量。还有可能是函数的返回类型为某表达式的值类型。在这些时候auto显得就无力了,所以C++11又引入了第二种类型说明符decltype,它的作用是选择并返回操作数的数据类型。在此过程中,编译器只是分析表达式并得到它的类型,却不进行实际的计算表达式的值。

int func() {return 0};
//普通类型
decltype(func()) sum = 5; // sum的类型是函数func()的返回值的类型int, 但是这时不会实际调用函数func()
int a = 0;
decltype(a) b = 4; // a的类型是int, 所以b的类型也是int
//不论是顶层const还是底层const, decltype都会保留   
const int c = 3;
decltype(c) d = c; // d的类型和c是一样的, 都是顶层const
int e = 4;
const int* f = &e; // f是底层const
decltype(f) g = f; // g也是底层const
//引用与指针类型
//1. 如果表达式是引用类型, 那么decltype的类型也是引用
const int i = 3, &j = i;
decltype(j) k = 5; // k的类型是 const int&
//2. 如果表达式是引用类型, 但是想要得到这个引用所指向的类型, 需要修改表达式:
int i = 3, &r = i;
decltype(r + 0) t = 5; // 此时是int类型
//3. 对指针的解引用操作返回的是引用类型
int i = 3, j = 6, *p = &i;
decltype(*p) c = j; // c是int&类型, c和j绑定在一起
//4. 如果一个表达式的类型不是引用, 但是我们需要推断出引用, 那么可以加上一对括号, 就变成了引用类型了
int i = 3;
decltype((i)) j = i; // 此时j的类型是int&类型, j和i绑定在了一起

(3)decltype(auto)

decltype(auto)是C++14新增的类型指示符,可以用来声明变量以及指示函数返回类型。在使用时,会将“=”号左边的表达式替换掉auto,再根据decltype的语法规则来确定类型。举个例子:

int e = 4;
const int* f = &e; // f是底层const
decltype(auto) j = f;//j的类型是const int* 并且指向的是e

《auto和decltype的用法总结》:https://www.cnblogs.com/XiangfeiAi/p/4451904.html

《C++11新特性中auto 和 decltype 区别和联系》:https://www.jb51.net/article/103666.htm


42、public,protected和private访问和继承权限/public/protected/private的区别?


  • public的变量和函数在类的内部外部都可以访问。
  • protected的变量和函数只能在类的内部和其派生类中访问。
  • private修饰的元素只能在类内访问。

(一)访问权限

派生类可以继承基类中除了构造/析构、赋值运算符重载函数之外的成员,但是这些成员的访问属性在派生过程中也是可以调整的,三种派生方式的访问权限如下表所示:注意外部访问并不是真正的外部访问,而是在通过派生类的对象对基类成员的访问。

派生类对基类成员的访问形象有如下两种:

  • 内部访问:由派生类中新增的成员函数对从基类继承来的成员的访问
  • 外部访问:在派生类外部,通过派生类的对象对从基类继承来的成员的访问

(二)继承权限

public继承

公有继承的特点是基类的公有成员和保护成员作为派生类的成员时,都保持原有的状态,而基类的私有成员任然是私有的,不能被这个派生类的子类所访问

protected继承

保护继承的特点是基类的所有公有成员和保护成员都成为派生类的保护成员,并且只能被它的派生类成员函数或友元函数访问,基类的私有成员仍然是私有的.

private继承

私有继承的特点是基类的所有公有成员和保护成员都成为派生类的私有成员,并不被它的派生类的子类所访问,基类的成员只能由自己派生类访问,无法再往下继承,访问规则如下表


43、如何用代码判断大小端存储


大端存储:字数据的高字节存储在低地址中

小端存储:字数据的低字节存储在低地址中

例如:32bit的数字0x12345678

所以在Socket编程中,往往需要将操作系统所用的小端存储的IP地址转换为大端存储,这样才能进行网络传输

小端模式中的存储方式为:

大端模式中的存储方式为:

了解了大小端存储的方式,如何在代码中进行判断呢?下面介绍两种判断方式:

方式一:使用强制类型转换-这种法子不错

#include <iostream>
using namespace std;
int main()
{
    int a = 0x1234;
    //由于int和char的长度不同,借助int型转换成char型,只会留下低地址的部分
    char c = (char)(a);
    if (c == 0x12)
        cout << "big endian" << endl;
    else if(c == 0x34)
        cout << "little endian" << endl;
}

方式二:巧用union联合体

#include <iostream>
using namespace std;
//union联合体的重叠式存储,endian联合体占用内存的空间为每个成员字节长度的最大值
union endian
{
    int a;
    char ch;
};
int main()
{
    endian value;
    value.a = 0x1234;
    //a和ch共用4字节的内存空间
    if (value.ch == 0x12)
        cout << "big endian"<<endl;
    else if (value.ch == 0x34)
        cout << "little endian"<<endl;
}

《写程序判断系统是大端序还是小端序》:https://www.cnblogs.com/zhoudayang/p/5985563.html


44、volatile、mutable和explicit关键字的用法


(1)volatile

volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改,比如:操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。

当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。

volatile定义变量的值是易变的,每次用到这个变量的值的时候都要去重新读取这个变量的值,而不是读寄存器内的备份。多线程中被几个任务共享的变量需要定义为volatile类型。

volatile 指针

volatile 指针和 const 修饰词类似,const 有常量指针和指针常量的说法,volatile 也有相应的概念

修饰由指针指向的对象、数据是 const 或 volatile 的:

const char* cpch;
volatile char* vpch;

指针自身的值——一个代表地址的整数变量,是 const 或 volatile 的:

char* const pchc;
char* volatile pchv;

注意:

  • 可以把一个非volatile int赋给volatile int,但是不能把非volatile对象赋给一个volatile对象。
  • 除了基本类型外,对用户定义类型也可以用volatile类型进行修饰。
  • C++中一个有volatile标识符的类只能访问它接口的子集,一个由类的实现者控制的子集。用户只能用const_cast来获得对类型接口的完全访问。此外,volatile向const一样会从类传递到它的成员。

多线程下的volatile  

有些变量是用volatile关键字声明的。当两个线程都要用到某一个变量且该变量的值会被改变时,应该用volatile声明,该关键字的作用是防止优化编译器把变量从内存装入CPU寄存器中。如果变量被装入寄存器,那么两个线程有可能一个使用内存中的变量,一个使用寄存器中的变量,这会造成程序的错误执行。volatile的意思是让编译器每次操作该变量时一定要从内存中真正取出,而不是使用已经存在寄存器中的值。

(2)mutable

mutable的中文意思是“可变的,易变的”,跟constant(既C++中的const)是反义词。在C++中,mutable也是为了突破const的限制而设置的。被mutable修饰的变量,将永远处于可变的状态,即使在一个const函数中。我们知道,如果类的成员函数不会改变对象的状态,那么这个成员函数一般会声明成const的。但是,有些时候,我们需要在const函数里面修改一些跟类状态无关的数据成员,那么这个函数就应该被mutable来修饰,并且放在函数后后面关键字位置。

(3)explicit

explicit关键字用来修饰类的构造函数,被修饰的构造函数的类,不能发生相应的隐式类型转换,只能以显示的方式进行类型转换,注意以下几点:

  • explicit 关键字只能用于类内部的构造函数声明上
  • explicit 关键字作用于单个参数的构造函数
  • 被explicit修饰的构造函数的类,不能发生相应的隐式类型转换


45、什么情况下会调用拷贝构造函数


  • 用类的一个实例化对象去初始化另一个对象的时候
  • 函数的参数是类的对象时(非引用传递)
  • 函数的返回值是函数体内局部对象的类的对象时 ,此时虽然发生(Named return Value优化)NRV优化,但是由于返回方式是值传递,所以会在返回值的地方调用拷贝构造函数

另:第三种情况在Linux g++ 下则不会发生拷贝构造函数,不仅如此即使返回局部对象的引用,依然不会发生拷贝构造函数

总结就是:即使发生NRV优化的情况下,Linux+ g++的环境是不管值返回方式还是引用方式返回的方式都不会发生拷贝构造函数,而Windows + VS2019在值返回的情况下发生拷贝构造函数,引用返回方式则不发生拷贝构造函数。

在c++编译器发生NRV优化,如果是引用返回的形式则不会调用拷贝构造函数,如果是值传递的方式依然会发生拷贝构造函数。

在VS2019下进行下述实验:

举个例子:

class A
{
public:
    A() {};
    A(const A& a)
    {
        cout << "copy constructor is called" << endl;
    };
    ~A() {};
};
void useClassA(A a) {}
A getClassA()//此时会发生拷贝构造函数的调用,虽然发生NRV优化,但是依然调用拷贝构造函数
{
    A a;
    return a;
}
//A& getClassA2()//  VS2019下,此时编辑器会进行(Named return Value优化)NRV优化,不调用拷贝构造函数 ,如果是引用传递的方式返回当前函数体内生成的对象时,并不发生拷贝构造函数的调用
//{
//    A a;
//    return a;
//}
int main()
{
    A a1, a2,a3,a4;
    A a2 = a1;  //调用拷贝构造函数,对应情况1
    useClassA(a1);//调用拷贝构造函数,对应情况2
    a3 = getClassA();//发生NRV优化,但是值返回,依然会有拷贝构造函数的调用 情况3
    a4 = getClassA2(a1);//发生NRV优化,且引用返回自身,不会调用
    return 0;
}

情况1比较好理解

情况2的实现过程是,调用函数时先根据传入的实参产生临时对象,再用拷贝构造去初始化这个临时对象,在函数中与形参对应,函数调用结束后析构临时对象

情况3在执行return时,理论的执行过程是:产生临时对象,调用拷贝构造函数把返回对象拷贝给临时对象,函数执行完先析构局部变量,再析构临时对象,  依然会调用拷贝构造函数

《C++拷贝构造函数详解》:https://www.cnblogs.com/alantu2018/p/8459250.html


46、C++中有几种类型的new


在C++中,new有三种典型的使用方法:plain new,nothrow new和placement new

(1)plain new

言下之意就是普通的new,就是我们常用的new,在C++中定义如下:

void* operator new(std::size_t) throw(std::bad_alloc);
void operator delete(void *) throw();

因此plain new在空间分配失败的情况下,抛出异常std::bad_alloc而不是返回NULL,因此通过判断返回值是否为NULL是徒劳的,举个例子:

#include <iostream>
#include <string>
using namespace std;
int main()
{
    try
    {
        char *p = new char[10e11];
        delete p;
    }
    catch (const std::bad_alloc &ex)
    {
        cout << ex.what() << endl;
    }
    return 0;
}
//执行结果:bad allocation

(2)nothrow new

nothrow new在空间分配失败的情况下是不抛出异常,而是返回NULL,定义如下:

void * operator new(std::size_t,const std::nothrow_t&) throw();
void operator delete(void*) throw();

举个例子:

#include <iostream>
#include <string>
using namespace std;
int main()
{
    char *p = new(nothrow) char[10e11];
    if (p == NULL) 
    {
        cout << "alloc failed" << endl;
    }
    delete p;
    return 0;
}
//运行结果:alloc failed

(3)placement new

这种new允许在一块已经分配成功的内存上重新构造对象或对象数组。placement new不用担心内存分配失败,因为它根本不分配内存,它做的唯一一件事情就是调用对象的构造函数。定义如下:

void* operator new(size_t,void*);
void operator delete(void*,void*);

使用placement new需要注意两点:

  • palcement new的主要用途就是反复使用一块较大的动态分配的内存来构造不同类型的对象或者他们的数组
  • placement new构造起来的对象数组,要显式的调用他们的析构函数来销毁(析构函数并不释放对象的内存),千万不要使用delete,这是因为placement new构造起来的对象或数组大小并不一定等于原来分配的内存大小,使用delete会造成内存泄漏或者之后释放内存时出现运行时错误。

举个例子:

#include <iostream>
#include <string>
using namespace std;
class ADT{
    int i;
    int j;
public:
    ADT(){
        i = 10;
        j = 100;
        cout << "ADT construct i=" << i << "j="<<j <<endl;
    }
    ~ADT(){
        cout << "ADT destruct" << endl;
    }
};
int main()
{
    char *p = new(nothrow) char[sizeof ADT + 1];
    if (p == NULL) {
        cout << "alloc failed" << endl;
    }
    ADT *q = new(p) ADT;  //placement new:不必担心失败,只要p所指对象的的空间足够ADT创建即可
    //delete q;//错误!不能在此处调用delete q;
    q->ADT::~ADT();//显示调用析构函数
    delete[] p;
    return 0;
}
//输出结果:
//ADT construct i=10j=100
//ADT destruct

《【C++】几种类型的new介绍》:https://www.jianshu.com/p/9b57e769c3cb


47、C++中NULL和nullptr区别


算是为了与C语言进行兼容而定义的一个问题吧

NULL来自C语言,一般由宏定义实现,而 nullptr 则是C++11的新增关键字。在C语言中,NULL被定义为(void*)0,而在C++语言中,NULL则被定义为整数0。编译器一般对其实际定义如下:

#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif

在C++中指针必须有明确的类型定义。但是将NULL定义为0带来的另一个问题是无法与整数的0区分。因为C++中允许有函数重载,所以可以试想如下函数定义情况:

#include <iostream>
using namespace std;
void fun(char* p) {
    cout << "char*" << endl;
}
void fun(int p) {
    cout << "int" << endl;
}
int main()
{
    fun(NULL);
    return 0;
}
//输出结果:int

那么在传入NULL参数时,会把NULL当做整数0来看,如果我们想调用参数是指针的函数,该怎么办呢?。nullptr在C++11被引入用于解决这一问题,nullptr可以明确区分整型和指针类型,能够根据环境自动转换成相应的指针类型,但不会被转换为任何整型,所以不会造成参数传递错误。

nullptr的一种实现方式如下:

const class nullptr_t{
public:
    template<class T>  inline operator T*() const{ return 0; }
    template<class C, class T> inline operator T C::*() const { return 0; }
private:
    void operator&() const;
} nullptr = {};

以上通过模板类和运算符重载的方式来对不同类型的指针进行实例化从而解决了(void*)指针带来参数类型不明的问题,另外由于nullptr是明确的指针类型,所以不会与整形变量相混淆。但nullptr仍然存在一定问题,例如:

#include <iostream>
using namespace std;
void fun(char* p)
{
    cout<< "char* p" <<endl;
}
void fun(int* p)
{
    cout<< "int* p" <<endl;
}
void fun(int p)
{
    cout<< "int p" <<endl;
}
int main()
{
    fun((char*)nullptr);//语句1
    fun(nullptr);//语句2
    fun(NULL);//语句3
    return 0;
}
//运行结果:
//语句1:char* p
//语句2:报错,有多个匹配
//3:int p

在这种情况下存在对不同指针类型的函数重载,此时如果传入nullptr指针则仍然存在无法区分应实际调用哪个函数,这种情况下必须显示的指明参数类型。

《NULL和nullptr区别》:https://blog.csdn.net/qq_39380590/article/details/82563571


48、简要说明C++的内存分区


C++中的内存分区,分别是堆、栈、自由存储区、全局/静态存储区、常量存储区和代码区。如下图所示

:在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限

:就是那些由 new分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个new就要对应一个 delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收

自由存储区:就是那些由malloc等分配的内存块,它和堆是十分相似的,不过它是用free来结束自己的生命的

全局/静态存储区:全局变量和静态变量被分配到同一块内存中,在以前的C语言中,全局变量和静态变量又分为初始化的和未初始化的,在C++里面没有这个区分了,它们共同占用同一块内存区,在该区定义的变量若没有初始化,则会被自动初始化,例如int型变量自动初始为0

常量存储区:这是一块比较特殊的存储区,这里面存放的是常量,不允许修改

代码区:存放函数体的二进制代码

《C/C++内存管理详解》:https://chenqx.github.io/2014/09/25/Cpp-Memory-Management/


49、C++的异常处理的方法


在程序执行过程中,由于程序员的疏忽或是系统资源紧张等因素都有可能导致异常,任何程序都无法保证绝对的稳定,常见的异常有:

  • 数组下标越界
  • 除法计算时除数为0
  • 动态分配空间时空间不足

如果不及时对这些异常进行处理,程序多数情况下都会崩溃。

(1)try、throw和catch关键字

C++中的异常处理机制主要使用trythrowcatch三个关键字,其在程序中的用法如下:

#include <iostream>
using namespace std;
int main()
{
    double m = 1, n = 0;
    try {
        cout << "before dividing." << endl;
        if (n == 0)
            throw - 1;  //抛出int型异常
        else if (m == 0)
            throw - 1.0;  //拋出 double 型异常
        else
            cout << m / n << endl;
        cout << "after dividing." << endl;
    }
    catch (double d) {
        cout << "catch (double)" << d << endl;
    }
    catch (...) {
        cout << "catch (...)" << endl;
    }
    cout << "finished" << endl;
    return 0;
}
//运行结果
//before dividing.
//catch (...)
//finished

代码中,对两个数进行除法计算,其中除数为0。可以看到以上三个关键字,程序的执行流程是先执行try包裹的语句块,如果执行过程中没有异常发生,则不会进入任何catch包裹的语句块。如果发生异常,则使用throw进行异常抛出,再由catch进行捕获,throw可以抛出各种数据类型的信息,代码中使用的是数字,也可以自定义异常class。

catch根据throw抛出的数据类型进行精确捕获(不会出现类型转换),如果匹配不到就直接报错,可以使用catch(…)的方式捕获任何异常(不推荐)。

当然,如果catch了异常,当前函数如果不进行处理,或者已经处理了想通知上一层的调用者,可以在catch里面再throw异常。

(2)函数的异常声明列表

有时候,程序员在定义函数的时候知道函数可能发生的异常,可以在函数声明和定义时,指出所能抛出异常的列表,写法如下:

int fun() throw(int,double,A,B,C){...};

这种写法表名函数可能会抛出int,double型或者A、B、C三种类型的异常,如果throw中为空,表明不会抛出任何异常,如果没有throw则可能抛出任何异常

(3)C++标准异常类  exception

C++ 标准库中有一些类代表异常,这些类都是从 exception 类派生而来的,如下图所示


  • bad_typeid:使用typeid运算符,如果其操作数是一个多态类的指针,而该指针的值为 NULL,则会拋出此异常,例如:
#include <iostream>
#include <typeinfo>
using namespace std;
class A{
public:
  virtual ~A();
};
using namespace std;
int main() {
    A* a = NULL;
    try {
          cout << typeid(*a).name() << endl; // Error condition
      }
    catch (bad_typeid){
          cout << "Object is NULL" << endl;
      }
    return 0;
}
//运行结果:bject is NULL
  • bad_cast:在用 dynamic_cast 进行从多态基类对象(或引用)到派生类的引用的强制类型转换时,如果转换是不安全的,则会拋出此异常
  • bad_alloc:在用 new 运算符进行动态内存分配时,如果没有足够的内存,则会引发此异常
  • out_of_range:用 vector 或 string的at 成员函数根据下标访问元素时,如果下标越界,则会拋出此异常

《C++异常处理(try catch throw)完全攻略》:http://c.biancheng.net/view/422.html


结束语


能看到这里的真的是真爱无疑了,送你一份校招求职分享 PPT 吧。这是我上次应邀在牛客校招求职分享直播中用到的直播 PPT,就是这篇「受宠若惊!有幸受官方邀请去做计算机专业求职直播分享中的用到的PPT。


相关文章
|
14天前
|
存储 编译器 对象存储
【C++打怪之路Lv5】-- 类和对象(下)
【C++打怪之路Lv5】-- 类和对象(下)
19 4