前言
以下问题以Q&A形式记录,基本上都是笔者在初学一轮后,掌握不牢或者频繁忘记的点
Q&A的形式有助于学习过程中时刻关注自己的输入与输出关系,也适合做查漏补缺和复盘。
本文对读者可以用作自查,答案在后面,需要时自行对照。
问题集
Q1:C++中是否有相关设计,类似实现“接口”的功能?
Q2:当我们在Class A类中定义了一个纯虚函数的时候,A类还可以被实例化吗?为什么?
Q3:我们如何合规地使用C++中的“接口”?
Q4:虚函数中 virtual 关键字对程序起到的实质性的作用?(注意区分虚继承)
Q5:override的书写,在函数多态中可有可无?
Q6:虚函数有哪些额外的开销?运行时成本?
Q8:release和debug版本的具体区别?
Q9:C++中默认的隐式转换可以做几次?
Q10:explicit关键字是用来干什么的?
Q11:以下4句语句的 Animal a 对象,各自分配在堆还是栈上?
在栈和堆上,各自作用域/生存周期有何不同?
Q12:作用域内变量自动销毁的特性,若想利用,可以有什么好的实践?(看看即可)
Q13:对于unique_ptr,为什么不能通过以下语法实现复制?
Q14:对于智能指针,为什么不建议通过new对象获得指针,而是使用 std::make_xx<> 方法?
Q15:关于强制转换,C风格的方法和C++风格的方法有何不同?
Q16:static_cast、dynamic_cast、const_cast 分别最主要用在什么地方?
Q17:dynamic_cast
比 static_cast
慢,为什么?
参考解答
Q1:C++中是否有相关设计,类似实现“接口”的功能?
A1:纯虚函数(pure virtual function),与java和C#中的接口定义比较接近
定义一个没有实现的函数,强制要求继承所属类的派生类去实现它。
其格式是 virtual ... func() = 0;
class Animal{
public:
int age = 10;
static const int sex = 1;
virtual void speak() = 0; // 声明纯虚函数
};
Q2:当我们在Class A类中定义了一个纯虚函数的时候,A类还可以被实例化吗?为什么?
A2:不可以,A类的纯虚函数被要求必须指定一个子类,去实现其接口。(重写这个当做“蓝图”的纯虚函数)
以上文中的例子,
Animal *a = new Animal; // 是不合法的语法
不能实例化含有纯虚函数的类对象的原因有以下几点:
-
接口不完整:含有纯虚函数的类定义了一个不完整的接口。这意味着基类本身并没有提供足够的实现细节来创建一个完整的对象。
-
强制实现:纯虚函数的存在是为了确保所有派生类都实现了这些函数。如果允许实例化含有纯虚函数的类,那么这种强制实现的约束就被破坏了。
Q3:我们如何合规地使用C++中的“接口”?
A3:谨慎地多重继承。实际上很多其他语言都有关键字interface,但是C++只有class
主要的代码可以是:
class InterFace{ ... 内有一个纯虚函数 }
...
class 需要这个接口的类 :public Base, InterFace{ ... 之后在这里重写纯虚函数 }
Q4:虚函数中 virtual 关键字对程序起到的实质性的作用?(注意区分虚继承)
A4:告诉编译器:“Hey,为我的这个函数创造一个vftable吧”。
如果这个函数被重写且被子类对象调用了,就按照重写(虚函数指针所指)的函数执行。
Q5:override的书写,在函数多态中可有可无?
A5:雀食可以省略,但是还是建议养成好习惯,坚持写上去增加可读性
class Cat : public Animal{
void speak() override {
cout << "Cat-speak" << endl;
}
};
Q6:虚函数有哪些额外的开销?运行时成本?
A6:1)我们需要额外的内存来存储v表,以分配到正确的函数,这会在Base类中多一个 *vfptr 的指针
2)在调用时,我们需要遍历这个表,来确定要映射到哪个函数
一般来说开销不会特别大,所以尽管用
Q7:我们在VS上写一个 "HelloWorld" 的时候,这个"HelloWorld"默认格式是string吗?
A7:不是诶,是 const char [ ] 类型!
在C++中,字符串字面量(如 `"Hello, World!"`)会被编译器识别为 const char[] 类型,但是当使用标准库中的输入输出流时,如`std::cout`,它会隐式地将const char*(即 const char[] 的指针类型)转换为 std::string
Q8:release和debug版本的具体区别?
A8:1)编译器优化力度有所区别 2)调试信息,debug版本通常更丰富一些
3)再者,初始化的变量值可能不同:
在debug版本下,有的未初始化变量可能是 0xcccccccc。
在release版本下,为了考虑性能这个部分内容应该是0x212B421F这样的随机脏数据
4)断言:Debug版本中,断言(assertions)通常是启用的,以在开发过程中捕获潜在的错误。而在Release版本中,断言可能被禁用,以避免影响程序性能。
Q9:C++中默认的隐式转换可以做几次?
我们假设有以下情境:
1)这里的 "Cherno" 是一个 char 数组
2)程序中有 class Entity ,带构造函数,构造函数可以构造属性 string Name = 输入;
A9:我们在调用 PrintEntity 函数的时候,编译器试图在进行两次隐式转换:
即,char[] 到 std::string 再到 Entity
但是隐式转换在C++中默认只能自动进行一次,所以这里产生了错误。
解决方式:选择以下写法中的任意一种
实际上,在这里我们使用了一种 “隐式构造函数” ,也就是经过隐式转化的构造函数,可以简化代码但最好避免使用。
Q10:explicit关键字是用来干什么的?
A10:主要用在构造函数上,要求显式类型调用构造函数,也就是强制要求禁止使用隐式转换。确保输入参数的类型安全
例如要使用构造这个Dog对象,则必须显式调用此构造函数
这个语法主要是影响这种表达:
Dog e = 22; // 调用的是dog的有参构造函数,相当于 e.age = (int)22
// 显然,这种方式会产生一次隐式转换,
// err:不存在从 "int" 转换到 "Dog" 的适当构造函数C/C++(415)
然而以下方式定义就不会有问题:
Dog e(22); 或者 Dog* e = new Dog(22); 原因是他们不会将构造函数的入参弄成 int(22)
根据 "Cherno" 是一个 char 数组,也要注意尽可能用 std::string 作为输入,而不要用默认
堆上数据和栈上数据的生存周期问题(重要)
Q11:以下4句语句的 Animal a 对象,各自分配在堆还是栈上?
在栈和堆上,各自作用域/生存周期有何不同?
A11:
1) Animal a;分配在栈空间上,他的作用域是整个main函数,因此会打印 create
2) {Animal a;}分配在栈空间上,不过他的作用域是他所在的代码块,因此会打印 create+destory
注意!这里 a 因为结束了代码块的执行,作用域一过,自动被释放!
3){ Animal a = Animal(); } 分配在栈空间上,作用域是他所在的代码块,打印 create+destory
4){Animal* a = new Animal();}分配在堆空间上,作用域是他所在的代码块,但会长期存在,打印 create
栈是一种会自动默认跟随生命周期管理的数据结构,但是堆不会。堆的特点就是只能手动申请和释放
虽然有些时候编译器会帮我们去保留一段时间,但是完全不建议这么做。
尤其是这种代码:
Q12:作用域内变量自动销毁的特性,若想利用,可以有什么好的实践?(看看即可)
A12:
注意:这些应用都只针对作用域!
1)作用域指针(ScopedPtr):大概意思是使用一个类,其对象含有一个private的 Entity *类型,有构造和析构
当我们即使是使用new申请堆空间的时候,也可以利用析构函数,达到自动释放的效果。
这就是智能指针(smart ptr,或是unique_ptr)做的最基本的事情!
2)Timer计时器对象:构造时创建,析构时输出打印。你只需要在函数开头,写一行代码,那么整个作用域会被计时
3)自动mutex:在作用域内锁住,出作用域时解锁
这里简单补充一点智能指针的知识:
1)unique_ptr,作用域指针:
不允许被复制,不会计数,会自动销毁
出作用域时会自动释放。不能被复制,也就不会导致有2个ptr指向同一块内存进而引发释放错误
格式:std::unique_ptr<类名> ptr = std::make_unique<类名>();
效果:最终不会得到一个没有引用的悬空指针 从而造成内存泄露
完整程序:
class Animal{
public:
Animal() {
cout << "create" << endl;
}
~Animal() {
cout << "destroy" << endl;
}
};
int main(){
{
std::unique_ptr<Animal> ptr = std::make_unique<Animal>();
}
while (1);
return 0;
}
2)共享指针 shared_ptr
允许被复制,会计数,会自动销毁
shared ptr的工作方式是通过引用计数,可以跟踪计数有多少个引用。当计数=0时才删除。(有点文件系统里面 inode或者信号量 的意思)
因为shared ptr需要分配另一块内存,叫做控制块,用来存储引用计数
3)弱指针 weak_ptr
允许被复制,不会计数,会自动销毁
Q13:对于unique_ptr,为什么不能通过以下语法实现复制?
std::unique_ptr<Entity> ptr_new = ptr;
A13:编译器会报错,因为 std::unique_ptr
没有拷贝构造函数。
Q14:对于智能指针,为什么不建议通过new对象获得指针,而是使用 std::make_xx<> 方法?
在 unique_ptr 中,不直接调用new的原因是因为异常安全。
当使用 new
创建对象时,如果对象的构造函数抛出异常,而程序员忘记使用智能指针来管理这个对象,那么这个对象将不会被正确地删除,导致资源泄漏。std::make_xx<>()
方法在内部处理了这些异常情况,确保了即使构造函数失败,资源也会被正确释放。
在shared_ptr中,std::make_xx<>()
主要是用于共享指针需要获得控制块变量++,以便计数。
另外,std::make_xx<>()
提供了一致的创建智能指针的方式。增加可读性,避免维护困难。
Q15:关于强制转换,C风格的方法和C++风格的方法有何不同?
A15:
C风格的方法: int a = (int)c
C++风格的方法:
1)静态转换:用于非多态类型的转换,如基本数据类型,编译时而非运行时进行检查
int a = 10;
double b = static_cast<double>(a); // 将int转换为double
2)动态转换:用于处理多态性,只能在含有虚函数的类层次结构中使用
class Base { virtual void dummy() {} }
class Derived : public Base {};
Base* basePtr = new Derived();
Derived* derivedPtr = dynamic_cast <Derived*>(basePtr);
if (derivedPtr) {
// 转换成功
}
3)const_cast
使得不能改写的const类型数据变得可以修改:
const int* a = new int(10);
int* b = const_cast<int*>(a); // 移除const限定符
Q16:static_cast、dynamic_cast、const_cast 分别最主要用在什么地方?
A16:
-
static_cast
:-
可用于基本数据类型之间的转换,如将
int
转换为char
或将float
转换为int
。 -
可用于类层次结构中的向上转型(从派生类向基类转换),因为这是安全的,编译器知道所有相关的信息
-
-
dynamic_cast
:- 主要用于处理多态性,即在运行时确定对象的实际类型。
- 只能用于包含虚函数的类的指针或引用类型的向下转型(从基类向派生类转换)。
- 如果转换失败,指针类型的
dynamic_cast
返回nullptr
,引用类型的转换会抛出std::bad_cast
异常。
-
const_cast
:- 主要用来修改类型的
const
限定符。 - 可以用来将
const
类型的指针或引用转换为非const
类型,或者反之。 - 转换不会改变对象本身是否是常量,只是改变指针或引用的
const
属性。 - 示例代码:
const int* constIntPtr = new int(5); int* modifiableIntPtr = const_cast<int*>(constIntPtr); *modifiableIntPtr = 10; // 现在可以修改值
- 主要用来修改类型的
Q17:dynamic_cast
比 static_cast
慢,为什么?
A17:因为它需要在运行时检查对象的实际类型