1.语言基础
(1)指针
-
定义:
- 指针是一个变量,用于存储另一个变量的内存地址。
-
特性:
- 可变性:指针可以重新指向不同的变量。
- 空指针:指针可以为空(即指向
nullptr
)。 - 大小:
sizeof
指针得到的是指针本身的大小(通常是4或8字节,取决于系统)。 - 操作:指针需要通过解引用操作符(
*
)才能访问或修改其指向的变量。 - 常量:可以有
const
和非const
区别,表示指针本身或指向的数据是否可以修改。
-
函数传递:
- 传指针时,将变量的地址传递给函数,函数通过解引用操作指向的变量。
(2)引用
-
定义:
- 引用是一个变量的别名,它必须在定义时初始化,并且不能改变其绑定对象。
-
特性:
- 不可变性:引用一旦绑定到一个变量,就不能改变其绑定的对象。
- 不能为空:引用必须在定义时绑定到一个有效的变量,不能为
nullptr
。 - 大小:
sizeof
引用得到的是引用对象的大小。 - 操作:对引用的操作直接作用于其绑定的对象,无需解引用操作符。
-
函数传递:
- 传引用时,函数直接操作传入的变量,无需解引用操作。
(3)C++函数参数传递方式
(1)值传递
A. 特性:
- 传递实参的拷贝
B. 注意:
- 指针传递也是值传递(传递地址值)
(2)引用传递
A. 特性:
- 本质就是实参别名(实参地址直接作为形参地址,引用在运行时存在,并作为实际的变量进行操作)
B. 注意:
- 引用必须初始化
- 引用一旦初始化,不能再绑定其他变量
(3)移动传递
A. 特性:
- 对象的资源从一个对象转移到另一个对象,而不是复制资源
B. 注意:
- 移动构造函数和移动赋值运算符的存在与否
- 使用
std::move
将左值转换为右值引用 - 移动后,原对象处于有效但未定义状态(通常为空)
- 需要对移动构造函数和移动赋值运算符进行
noexcept
声明以确保异常安全(noexcept说明该函数不会抛出异常,优化性能)
(4)标识符(identifier)
是用于标识变量、函数、类型、类、对象等各种编程元素的名称。标识符本身不是一种类型,但它可以用于命名特定类型的变量、函数或其他编程元素。
内层标识符 是相对于 外层标识符 而言的,主要涉及作用域的概念。作用域决定了标识符在程序中的可见范围。当一个标识符在某个嵌套作用域中定义时,它被称为内层标识符。
(5)数据类型及其字节大小
基本数据类型
-
布尔型 (bool)
- 通常大小为 1 字节
-
字符型 (char)
- 大小为 1 字节
-
宽字符型 (wchar_t)
- 通常大小为 2 或 4 字节,取决于实现
-
整型 (integer)
short
: 通常大小为 2 字节int
: 通常大小为 4 字节long
: 通常大小为 4 字节long long
: 通常大小为 8 字节
-
无符号整型 (unsigned integer)
unsigned short
: 通常大小为 2 字节unsigned int
: 通常大小为 4 字节unsigned long
: 通常大小为 4 字节unsigned long long
: 通常大小为 8 字节
-
浮点型 (floating-point)
float
: 通常大小为 4 字节double
: 通常大小为 8 字节long double
: 通常大小为 8 或 16 字节,取决于实现
复合数据类型
-
数组 (Array)
- 大小取决于元素类型和数组长度
-
结构体 (Struct)
- 大小为其成员大小的总和,可能会因对齐(padding)而增加
-
联合体 (Union)
- 大小为其最大成员的大小
-
枚举 (Enum)
- 大小通常与
int
相同,但具体取决于实现
- 大小通常与
-
指针 (Pointer)
- 大小通常为 4 字节(32 位系统)或 8 字节(64 位系统)
2.语言特性
1. 智能指针
(1)指针常见问题
悬空指针:
指向已经被释放或未分配内存的指针。
delete ptr;
// ptr = nullptr; // 释放指针指向的内容后,指针仍然指向旧地址
如何避免悬空指针
-
释放内存后将指针置为
nullptr
:int* p = new int(42); delete p; p = nullptr;
-
避免多次释放同一块内存:确保每块内存只释放一次,防止指针再次指向已释放的内存。
int* p = new int(42); delete p; p = nullptr; // 这样可以避免再次delete
-
使用智能指针:智能指针会自动处理内存释放,避免悬空指针的问题。
std::unique_ptr<int> p = std::make_unique<int>(0);
野指针:
指向未初始化的指针,指向一个未知的内存位置。
如何避免野指针
-
指针初始化:在定义指针时,将其初始化为
nullptr
,确保指针在使用前被赋予一个有效的内存地址。int* p = nullptr;
-
动态分配内存时立即初始化:
int* p = new int(0); // 分配并初始化为 0
-
使用智能指针:在 C++11 及更高版本中,使用智能指针如
std::unique_ptr
或std::shared_ptr
,它们会自动管理内存,避免未初始化指针的使用。std::unique_ptr<int> p = std::make_unique<int>(0);
循环引用(Circular References):
- 当两个
std::shared_ptr
相互引用时,会导致内存泄漏,因为引用计数不会降到 0,资源不会被释放。 - 解决方案是使用
std::weak_ptr
打破循环引用。- 例如:
std::shared_ptr<A> a = std::make_shared<A>(); std::shared_ptr<B> b = std::make_shared<B>(); a->b = b; b->a = a;
- 例如:
不正确的使用 std::weak_ptr
:
- 访问
std::weak_ptr
前没有提升(lock
)为std::shared_ptr
会导致未定义行为。- 例如:
std::weak_ptr<int> wp; std::shared_ptr<int> sp = wp.lock(); *sp = 10;
需要确保wp
有效。
- 例如:
过度使用智能指针:
- 不必要地使用智能指针会增加程序复杂性和性能开销。
- 例如:在小型对象或不需要动态分配的场合下使用智能指针。
误用 std::unique_ptr
:
std::unique_ptr
不允许复制,只能移动。当误用时会引发编译错误。- 例如:
std::unique_ptr<int> p1(new int(10)); std::unique_ptr<int> p2 = p1;
应该使用p2 = std::move(p1);
- 例如:
内存泄漏:
- 虽然智能指针可以自动管理内存,但如果智能指针的生命周期管理不当,仍可能导致内存泄漏。
- 例如:在容器中存储智能指针时,未正确处理指针生命周期。
(2)auto_ptr
特性:
- 所有权转移,行为更接近于现代 C++ 中的移动语义。
注意:
-
auto_ptr
所有权转移机制不直观且容易导致潜在的内存崩溃问题。std::auto_ptr<std::string> p1(new std::string("hello")); std::auto_ptr<std::string> p2; p2 = p1; // p2 剥夺了 p1 的所有权。std::cout << *p2 << std::endl; // 这可以正常工作,因为 p2 现在拥有该字符串。 // std::cout << *p1 << std::endl; // 这会导致运行时错误,因为 p1 不再拥有该字符串。
(3)unique_ptr
特性:
-
独有式管理,确保一个对象同一时间只有一个
unique_ptr
管理。不允许复制操作(会报错)。
std::unique_ptr<std::string> p3(new std::string("auto")); // 创建一个 unique_ptr,指向字符串 "auto"
std::unique_ptr<std::string> p4; // 创建一个空的 unique_ptr// 尝试复制 unique_ptr 会导致编译错误
// p4 = p3; // 此行代码会报错,原因如下:
注意:
- 不能直接传递
unique_ptr
给需要复制参数的函数,而应该传递其引用或使用std::move
来转移所有权。 - 转移所有权后,指针为空。
(4)shared_ptr
特性:
- 指针
ptr1
拷贝复制给另一个指针ptr2
(引用次数加一)。
注意:
- 初始化两个
shared_ptr
,ptr1
和ptr2
同时指向同一对象时(两个指针引用次数独立),可能会出现双重释放的问题。
#include <iostream>
#include <memory>class Car {
public:Car() { std::cout << "Car constructed\n"; }~Car() { std::cout << "Car destructed\n"; }
};int main() {Car* rawCar = new Car(); // 手动创建一个 Car 对象std::shared_ptr<Car> ptr1(rawCar); // 引用计数为 1std::shared_ptr<Car> ptr2(rawCar); // 引用计数为 1(独立的计数)std::cout << "ptr1 use count: " << ptr1.use_count() << std::endl; // 输出 1std::cout << "ptr2 use count: " << ptr2.use_count() << std::endl; // 输出 1// 当 ptr1 和 ptr2 作用域结束时,两者都会尝试删除同一个原始指针return 0;
}
2.new
/delete和malloc
/free
new 和 delete
new:
new
是一个运算符,用来分配内存并初始化对象。- 执行
new
实际上有两个步骤:- 分配内存:
new
会调用底层的malloc
函数分配一块未初始化的内存空间。 - 初始化对象:
new
会调用对象的构造函数在分配的内存上构造对象。
- 分配内存:
new
返回的是经过初始化的对象的指针。- 如果内存分配失败,会抛出
std::bad_alloc
异常,或者被自定义的异常处理函数捕获。 - 如果在构造对象时出现异常,
new
会自动调用delete
释放之前分配的内存。
delete:
delete
是一个运算符,用来释放内存并销毁对象。- 执行
delete
实际上有两个步骤:- 销毁对象:
delete
会调用对象的析构函数,执行清理操作。 - 释放内存:
delete
会调用底层的free
函数释放内存。
- 销毁对象:
delete
用于释放单个由new
分配的对象。delete[]
用于释放由new[]
分配的数组对象。
malloc 和 free
malloc:
malloc
是一个库函数,用来分配一块未初始化的内存。malloc
只分配指定字节数的内存,不会调用任何构造函数。malloc
返回的是void*(通用指针),如果内存分配失败,返回NULL
。
分配了 sizeof(int) * 10
字节的内存,并将 void*
转换为 float*
时,虽然语法上允许这种转换,但实际使用中必须确保内存分配的大小和类型匹配,否则会导致未定义行为和内存访问错误。
- 内存越界访问:如果
int
的大小与float
的大小不同,那么在访问数组元素时,可能会导致访问超出实际分配内存的边界。 - 数据解释错误:即使内存分配大小足够,类型不匹配会导致数据被错误解释。
int
和float
的二进制表示不同,因此读取或写入数据时会出现问题。
free:
free
是一个库函数,用来释放由malloc
分配的内存。free
只释放内存,不会调用任何析构函数。
关键区别
-
内存初始化:
new
分配内存并调用构造函数初始化对象。malloc
只分配未初始化的内存。
-
内存释放:
delete
调用析构函数销毁对象,然后释放内存。free
只释放内存,不调用析构函数。
-
异常处理:
new
在内存分配失败时抛出异常。malloc
在内存分配失败时返回NULL
。
-
类型安全:
new
是类型安全的,返回特定类型的指针。malloc
只返回void*
,需要手动进行类型转换。
-
编译器控制:
new
和delete
是运算符,由编译器控制,可以结合构造函数和析构函数的调用。malloc
和free
是库函数,不在编译器的控制范围内,无法自动调用构造函数和析构函数。
为什么需要 new 和 delete?
对于非内部数据类型(如类对象)而言,仅使用 malloc
和 free
无法满足动态对象管理的需求。对象在创建时需要自动执行构造函数,在销毁前需要自动执行析构函数。由于 malloc
和 free
是库函数,不是运算符,编译器无法控制其行为,因此不能将构造和析构的任务强加于 malloc
和 free
。这就是 new
和 delete
存在的原因。
总结起来,new
和 delete
提供了更高层次的抽象,简化了内存管理,确保了对象的正确构造和析构,是面向对象编程的必要工具。
3. C++ 四种强制转换
- 上行转换(Upcasting):指从派生类指针或引用转换到基类指针或引用。这是安全的,因为派生类是基类的一种扩展。
- 下行转换(Downcasting):指从基类指针或引用转换到派生类指针或引用。这可能是不安全的,因为基类指针或引用可能并不实际指向该派生类的对象。
1. static_cast
- 用途:用于在编译时进行类型转换。
- 特性:无运行时检查,上行转换安全,下行转换不安全。
- 理解:
- 无运行时检查:
static_cast
仅在编译时检查类型兼容性,转换操作在运行时不进行额外的检查。 - 上行转换安全:派生类指针/引用转换为基类指针/引用,基类是派生类的一部分,所以是安全的。
- 下行转换不安全:基类指针/引用转换为派生类指针/引用,没有检查转换的合法性,可能会导致未定义行为。
- 无运行时检查:
-
int main() {double d = 3.14;int i = static_cast<int>(d); // 将 double 转换为 int,丢失小数部分 }
2. dynamic_cast
- 用途:用于在多态类型之间进行安全的类型转换。
- 特性:有运行时类型检查,下行转换安全,类型不一致时返回空指针。
- 理解:
- 有运行时类型检查:
dynamic_cast
会在运行时检查实际类型是否匹配,确保转换的安全性。 - 下行转换安全:基类指针/引用转换为派生类指针/引用时,如果类型不匹配,返回
nullptr
(指针)或抛出异常(引用)。 - 类型不一致时返回空指针:当尝试转换的类型不兼容时,
dynamic_cast
会返回空指针以避免无效转换。
- 有运行时类型检查:
-
#include <iostream>class Base { public:virtual ~Base() {} };class Derived : public Base {};int main() {Base* b = new Derived;Derived* d = dynamic_cast<Derived*>(b); // 安全的下行转换if (d) {std::cout << "Conversion successful" << std::endl;} else {std::cout << "Conversion failed" << std::endl;}delete b; }
3. const_cast
- 用途:用于添加或移除
const
属性。 - 特性:唯一能操作常量属性的转换符。
- 理解:
- 添加或移除
const
属性:const_cast
可以去除const
修饰符,允许修改常量数据;也可以添加const
修饰符,确保数据不被修改。 - 唯一能操作常量属性:
const_cast
是四种转换操作符中唯一专门用于修改常量属性的转换操作符。
- 添加或移除
-
int main() {const int a = 10;int* p = const_cast<int*>(&a);*p = 20; // 修改 const 变量(未定义行为) }
4. reinterpret_cast
- 用途:用于进行低级别的类型重新解释。
- 特性:高危操作,平台相关,需谨慎使用。
- 理解:
- 低级别的类型重新解释:
reinterpret_cast
允许将一种类型的指针或引用直接转换为另一种不相关的类型,这种转换不会改变数据的比特位。 - 高危操作:这种转换可能导致未定义行为,应谨慎使用。
- 平台相关:转换结果依赖于具体的平台,可能在不同平台上表现不同,不具有可移植性。
- 低级别的类型重新解释:
4.虚函数
(1)虚函数表和动态多态性
在C++中,虚函数用于实现动态多态性,通过虚函数表(vtable)和虚函数表指针(vptr)机制来实现。
-
虚函数表(vtable):
- 每个包含虚函数的类都有一个独立的虚函数表,存储该类声明的虚函数的地址。
- 虚函数表在编译时生成,对每个类来说是唯一的。
-
虚函数表指针(vptr):
- 每个包含虚函数的对象都有一个隐藏的虚函数表指针(vptr),指向对象所属类的虚函数表。
- 在对象创建时,vptr被初始化为指向该对象所属类的虚函数表。
(2)虚函数工作原理:
1.虚函数表地址变化:
给每个对象添加一个隐藏成员。隐藏成员中保存一个指向函数地址数组(虚函数表)的指针(虚函数指针),虚函数表存储了为类对象进行声明的虚函数的地址。
-
基类的虚函数表:
- 基类的虚函数表在内存中是一个独立的实体,其内容在类定义和编译时确定。
- 基类的虚函数表存储基类声明的虚函数的地址,即使派生类重写了这些虚函数,基类的虚函数表仍然保持不变。
-
派生类的虚函数表:
- 派生类的虚函数表也是一个独立的实体。
- 当派生类重写了基类的虚函数时,派生类的虚函数表中相应位置的地址会被更新为派生类实现的虚函数的地址。
- 派生类没有重写的虚函数,其虚函数表中对应位置的地址会继续存储基类版本的虚函数地址。
-
虚函数表地址变化:
- 基类和派生类的虚函数表地址在内存中是不同的。
- 基类的虚函数表地址和内容在派生类重写虚函数后保持不变。
- 派生类的虚函数表在重写虚函数后更新了相应位置的地址。
class Base {
public:// 虚函数func1和func2的定义virtual void func1() {std::cout << "Base::func1" << std::endl;}virtual void func2() {std::cout << "Base::func2" << std::endl;}// Base_vtable:// [0] 0x1000 (Base::func1)// [1] 0x1004 (Base::func2)
};class Derived : public Base {
public:// 重写虚函数func1void func1() override {std::cout << "Derived::func1" << std::endl;}// 派生类没有重写func2// Derived_vtable:// [0] 0x2000 (Derived::func1)// [1] 0x1004 (Base::func2)
};
2.this指针
定义
- this 指针是一个隐含参数,存在于每个非静态成员函数中,指向调用该成员函数的对象(即当前对象)。
特性
-
隐含参数:
- 在每个非静态成员函数中,
this
指针是隐含的,即程序员不需要显式地声明它。
- 在每个非静态成员函数中,
-
指向对象:
this
指针指向调用成员函数的对象,即当前对象。可以用来访问该对象的成员变量和其他成员函数。
用途
-
访问成员变量:
this
指针可以用来访问对象的成员变量,避免名称冲突。
class MyClass { private:int value; public:MyClass(int value) {this->value = value; // 使用 this 指针访问成员变量}void display() {std::cout << "Value: " << this->value << std::endl;} };
-
调用成员函数:
this
指针可以用来调用对象的其他成员函数。
class MyClass { public:void func1() {std::cout << "func1 called" << std::endl;}void func2() {this->func1(); // 使用 this 指针调用另一个成员函数} };
-
返回对象自身:
- 在成员函数中,可以使用
this
指针返回对象自身的引用。
class MyClass { public:MyClass& setValue(int value) {this->value = value;return *this;} private:int value; };
- 在成员函数中,可以使用
(1)为什么 this 指针对虚函数很重要
-
虚函数的动态绑定:
- 虚函数依赖于
this
指针来实现动态绑定。每个对象都有一个虚表指针(vptr),指向该类的虚表(vtable)。在调用虚函数时,通过this
指针找到对象的虚表,再通过虚表找到正确的函数地址进行调用。
class Base { public:virtual void display() {std::cout << "Base display" << std::endl;} };class Derived : public Base { public:void display() override {std::cout << "Derived display" << std::endl;} };int main() {Base* obj = new Derived();obj->display(); // 输出 "Derived display",通过 this 指针实现动态绑定delete obj; }
- 虚函数依赖于
(2)不能有 this 指针的函数
-
静态成员函数:
- 静态成员函数属于类本身而不是某个对象,因此没有
this
指针。
class MyClass { public:static void staticFunc() {// 静态函数没有 this 指针} };
- 静态成员函数属于类本身而不是某个对象,因此没有
-
普通非成员函数:
- 普通非成员函数不属于任何类,因此没有
this
指针。
void normalFunc() {// 非成员函数没有 this 指针 }
- 普通非成员函数不属于任何类,因此没有
(3)编译器处理虚函数表的过程
在C++中,编译器处理虚函数表的过程主要涉及以下几个步骤,特别是在处理派生类时。这些步骤确保了在运行时能够正确地进行虚函数的调用,从而实现多态性。
-
拷贝基类的虚函数表:
- 如果派生类继承自基类,编译器首先拷贝基类的虚函数表。如果是多继承,则拷贝每个有虚函数的基类的虚函数表。
- 有时会有一个基类被称为主基类(primary base class),其虚函数表可能会被直接继承并共用。
-
替换虚函数:
- 编译器查看派生类中是否重写了基类中的虚函数。如果有重写,则在派生类的虚函数表中替换相应的虚函数地址为派生类实现的地址。
-
添加派生类自己的虚函数:
- 编译器检查派生类是否有新的虚函数。如果有,则将这些新的虚函数添加到派生类的虚函数表中。
(4)虚继承
特性:
- 单一实例化:虚继承确保基类在派生类中只实例化一次,即使通过多条路径继承。
- 虚基类表(vbase table):虚继承引入虚基类表,其中包含指向虚基类的指针,确保基类实例的唯一性。
- 菱形继承问题的解决:虚继承通过确保基类实例唯一,解决了菱形继承导致的数据冗余和不确定性问题。
class A {
public:int value;
};class B : virtual public A {
};class C : virtual public A {
};class D : public B, public C {
};
注意事项:
-
构造函数初始化列表:
- 在派生类的构造函数中,必须显式初始化虚基类。即使
B
和C
初始化了A
,在D
中也必须再次初始化A
。 -
class A { public:A(int v) : value(v) {}int value; };class B : virtual public A { public:B(int v) : A(v) {} };class C : virtual public A { public:C(int v) : A(v) {} };class D : public B, public C { public:D(int v) : A(v), B(v), C(v) {} };
- 在派生类的构造函数中,必须显式初始化虚基类。即使
2.访问控制:
- 访问虚基类成员时,可能需要使用作用域解析运算符明确指定基类。
-
D obj(10); obj.A::value = 20; // 需要指定作用域
底层实现
1. 虚基类指针(VBP)
- 位置:紧跟在虚函数指针(vptr)之后。
- 功能:指向虚基类表(VBT),用于定位虚基类实例的位置,确保多重继承情况下基类实例的唯一性。
2. 虚基类表(VBT)
- 记录内容:虚基类表记录了虚基类实例在派生类对象中的偏移量。
- 编译器的作用:编译器在生成派生类对象时,使用这些偏移量来定位虚基类实例的位置。
3. 数据成员布局
- 数据成员:对象包含的所有数据成员,包括继承自基类和派生类的成员。
- 虚基类成员:虚基类的成员位于对象内存布局的最后,确保所有派生类共享一个基类实例。
步骤:
编译器会执行以下步骤:
- 获取
D
对象的基地址,例如base_addr
。 - 通过
B
或C
的 VBP 访问VBT
。 - 从
VBT
中获取A
类实例的偏移量。 - 计算
A
实例的地址为base_addr + offset
。 - 访问
A
实例的value
成员。
class A {
public:int value;
};class B : virtual public A {
};class C : virtual public A {
};class D : public B, public C {
public:D(int v) : A(v), B(v), C(v) {// 在这里,编译器会自动调整 B 和 C 的 VBT,使其指向 D 中的 A 实例
}};D:- B:- VBP (虚基类指针)- B 的数据成员- VBT (虚基类表)[A offset] -> 从D对象的基地址到A实例的偏移量- C:- VBP (虚基类指针)- C 的数据成员- VBT (虚基类表)[A offset] -> 从D对象的基地址到A实例的偏移量- A:- value (A 的数据成员)- D 的数据成员
(5)虚函数使用注意事项
(1)指针,引用隐式向上转换?
当派生类继承基类时,派生类对象包含基类对象的所有特性。这意味着派生类对象在结构上是基类对象的一个超集。由于这种包含关系,派生类对象可以被当作基类对象处理。
- 当派生类重写了基类的虚函数时,派生类的虚函数表中相应位置的地址会被更新为派生类实现的虚函数地址。
- 虚函数表的位置在类层次结构中是固定的,因此基类指针或引用指向派生类对象时,通过虚函数表调用虚函数,会调用派生类的实现。
- 这种机制确保了在运行时能够正确调用派生类重写的虚函数,实现多态性。
(2)析构函数一般写成虚函数的原因
当一个基类指针指向一个派生类对象时,如果基类的析构函数不是虚函数,删除该对象时,只会调用基类的析构函数,而不会调用派生类的析构函数。这样会导致派生类的资源无法正确释放,可能会导致内存泄漏。
class Base {
public:virtual ~Base() { std::cout << "Base Destructor" << std::endl; }
};class Derived : public Base {
public:~Derived() { std::cout << "Derived Destructor" << std::endl; }
};void func() {Base* b = new Derived();delete b; // 调用Derived和Base的析构函数
}
(3)构造函数为什么一般不定义为虚函数
- 创建对象需要完整信息:创建对象时需要知道对象的确切类型及其所有成员的完整信息,而虚函数只需要知道函数接口。
- 虚函数表指针(vptr)依赖于对象实例化后:对象的虚函数指针(vptr)是在对象实例化过程中设置的。只有在对象实例化之后,虚函数指针才存在,并指向对应的虚函数表(vtable)
- 如果构造函数是虚的,那么在对象实例化之前,虚函数表指针不存在,无法找到对应的虚函数表来调用虚函数。
(4)构造函数或析构函数中调⽤虚函数
1.理解构造函数中的虚函数调用
当创建一个派生类对象时,构造过程如下:
- 首先调用基类构造函数初始化基类部分。
- 然后调用派生类构造函数初始化派生类部分。
在基类构造函数执行时,派生类部分还没有被初始化。因此,在基类构造函数中调用虚函数时,只会调用基类的版本,而不会调用派生类的版本。此时,编译器认为对象是基类类型的对象。
2.理解析构函数中的虚函数调用
当销毁一个派生类对象时,析构过程如下:
- 首先调用派生类析构函数。
- 然后调用基类析构函数销毁基类部分。
在派生类析构函数执行完毕后,派生类部分已经被销毁。如果在基类析构函数中调用虚函数,此时对象的类型被认为是基类类型,派生类部分已经不存在,因此只会调用基类的版本。
(5)哪些函数不能是虚函数
在C++中,不是所有函数都能被声明为虚函数。以下是不能被声明为虚函数的几类函数及其原因:
-
构造函数:
- 原因:构造函数用于初始化对象。在构造函数执行时,派生类的构造函数需要知道基类构造函数做了什么。如果构造函数是虚函数,那么在构造基类对象时,虚表指针尚未初始化完毕,因此无法正确调用派生类的构造函数。
class Base { public:virtual Base() {} // 错误,构造函数不能是虚函数 };
-
内联函数:
- 原因:内联函数在编译阶段进行函数体替换操作,而虚函数在运行期间进行类型确定。由于这两者在处理时间上的矛盾,内联函数不能是虚函数。
class Base { public:virtual inline void func() {} // 错误,内联函数不能是虚函数 };
-
静态函数:
- 原因:静态函数属于类而不是对象,没有
this
指针,虚函数依赖this
指针进行动态绑定。因此,静态函数不能是虚函数。
class Base { public:virtual static void func() {} // 错误,静态函数不能是虚函数 };
- 原因:静态函数属于类而不是对象,没有
-
友元函数:
- 原因:友元函数不是类的成员函数,不能被继承。由于没有继承关系,友元函数不能是虚函数。
class Base { public:friend virtual void func(Base& b); // 错误,友元函数不能是虚函数 };
-
普通函数:
- 原因:普通函数不是类的成员函数,不具备继承特性,因此也不能是虚函数。
virtual void globalFunc() {} // 错误,普通函数不能是虚函数
深拷贝和浅拷贝的区别
浅拷贝
定义:浅拷贝仅复制对象的所有成员的值,包括指针成员的地址。
特点:
- 复制对象的所有成员值,但不复制指针所指向的内容。
- 如果对象包含指针成员,浅拷贝后两个对象的指针成员将指向相同的内存地址。
- 当其中一个对象被销毁时,指针指向的内存可能被释放,导致另一个对象使用非法内存。
步骤
- 普通成员:直接复制其值。
- 指针成员:复制指针的地址,使得多个对象共享同一块内存。
- 潜在问题:如果一个对象释放了内存,另一个对象仍然试图访问这块内存,会导致悬空指针问题。
#include <iostream>class ShallowCopy {
public:int* data;ShallowCopy(int value) {data = new int(value);}// 拷贝构造函数(浅拷贝)ShallowCopy(const ShallowCopy& other) : data(other.data) {}// 析构函数~ShallowCopy() {delete data;}
};int main() {ShallowCopy obj1(5);ShallowCopy obj2 = obj1; // 浅拷贝,obj2.data 指向与 obj1.data 相同的内存std::cout << "obj1 data: " << *obj1.data << std::endl; // 输出 5std::cout << "obj2 data: " << *obj2.data << std::endl; // 输出 5// obj1 析构函数被调用,释放 data 所指向的内存// 但 obj2.data 仍然指向同一块内存,导致悬空指针return 0; // 当 obj2 被析构时,访问已释放的内存,可能导致程序崩溃
}
(1)为什么拷⻉构造函数必需时引⽤传递,不能是值传递?
当一个对象被值传递时,编译器会按照以下步骤处理:
-
调用拷贝构造函数: 编译器会调用该类的拷贝构造函数来创建对象的副本。
-
执行拷贝构造函数中的复制逻辑: 在拷贝构造函数中,你可以定义如何复制对象的成员变量,包括动态内存分配和其他复杂的复制操作。
class A {
public:A(const A a) { // 值传递,错误// 构造函数体}
};int main() {A a1;A a2 = a1; // 这里会调用拷贝构造函数
}
(2)拷贝构造函数会在以下三种情况下被调用:
1.对象以值传递的方式传入函数: 当一个对象以值传递的方式作为函数参数时,编译器会调用拷贝构造函数来创建该对象的副本,从而将副本传递给函数。
void function(A obj) {// 函数体
}int main() {A a1;function(a1); // 调用拷贝构造函数
}
2.对象以值传递的方式从函数返回: 当一个函数以值传递的方式返回对象时,编译器会调用拷贝构造函数来创建该对象的副本,从而将副本作为返回值返回。
class A {
public:A() {// 默认构造函数}A(const A& other) {// 拷贝构造函数}
};A function() {A a1; // 1. 调用默认构造函数,创建局部对象 a1return a1; // 2. 返回 a1,编译器会调用拷贝构造函数来创建 a1 的副本 (临时对象)
}int main() {A a2 = function(); // 3. 接收 function 返回的临时对象,编译器会再次调用拷贝构造函数
}
3.对象通过另一个对象进行初始化: 当一个对象通过另一个对象进行初始化时,编译器会调用拷贝构造函数来创建新对象的副本。
int main() {A a1;A a2 = a1; // 调用拷贝构造函数A a3(a1); // 调用拷贝构造函数
}
深拷贝
定义:深拷贝不仅复制对象的所有成员的值,还为指针成员分配新的内存并复制指针所指向的内容。
特点:
- 复制对象的所有成员值,并为指针成员分配新的内存,复制内容。
- 每个对象有自己独立的内存空间,避免了共享内存带来的问题。
- 更加安全,适用于包含指针成员的类。
步骤:
- 普通成员:直接复制。
- 指针成员:重新分配内存,并将原指针指向的内容复制到新指针所指向的内存中。
#include <iostream>class DeepCopy {
public:int* data;DeepCopy(int value) {data = new int(value);}// 深拷贝构造函数DeepCopy(const DeepCopy& other) {data = new int(*other.data);}~DeepCopy() {delete data;}
};int main() {DeepCopy obj1(5);DeepCopy obj2 = obj1; // 深拷贝std::cout << "obj1 data: " << *obj1.data << std::endl;std::cout << "obj2 data: " << *obj2.data << std::endl;return 0;
}
3.关键字
(1)const:
const 关键字用于表示常量,保护数据不被修改。其应用包括:
-
修饰基本数据类型:
- 修饰符位置:const 可以在类型说明符前或后使用,效果相同。使用时不可修改其值。
-
const int a = 10; // a 是一个不可修改的整数 int const b = 20; // b 也是一个不可修改的整数
-
修饰指针和引用变量:
- 指针本身为常量:const 在星号右侧,表示指针本身不可变,指向的整数可以修改
-
int* const ptr = &value;
- 指向常量的指针: const 在星号左侧,表示指针指向值不变
-
const int* ptr = &value; // ptr 是一个指向常量整数的指针 int const* ptr2 = &value; // ptr2 也是一个指向常量整数的指针
- 引用常量: 不可以通过这个引用修改这个值
-
int value = 10; const int& ref = value;
- 修饰函数参数
1.保护参数:
void display(const std::string& text) {// 函数体内不能修改 textstd::cout << text << std::endl;
}
2.保护返回值:
const std::string& getName() const {return name;
}
3.在类中的应用
1. const 成员变量
定义和用法:
const
成员变量在某个对象的生命周期内是常量,但对于整个类而言可以不同。- 由于类的对象在没有创建时,编译器不知道
const
数据成员的值是什么,所以不能在类的声明中初始化const
数据成员。 const
数据成员的初始化只能在类的构造函数的初始化列表中进行。
#include <iostream>class MyClass {
public:const int value;MyClass(int val) : value(val) {} // 在构造函数初始化列表中初始化void showValue() const { // const 成员函数std::cout << "Value is: " << value << std::endl;}
};int main() {MyClass obj1(10);MyClass obj2(20);obj1.showValue(); // 输出:Value is: 10obj2.showValue(); // 输出:Value is: 20return 0;
}
2. const 成员函数
具体解释
- const 成员函数:该函数不会修改对象的成员变量(只会读取对象的状态,而不会改变它)
- mutable 关键字:
const
成员函数中能够被修改的成员变量,可以使用mutable
关键字进行修饰。
注意:
(1)常量对象只能调用常量成员函数
定义和用法:
- 一个被
const
修饰的对象称为常量对象。(对象不可变) - 常量对象只能调用
const
成员函数,不能调用其他会修改对象状态的成员函数。
进一步理解:
(1)类中每个成员函数都有一个隐式指针指向调用对象(当前类对象)
非const成员函数:有一个classname * const 的常量隐式指针(故可以改变对象成员变量)
const成员函数: 有一个const classname * 的指向常量的隐式指针(不可改变对象成员变量)
3. 非常量对象的调用规则
- 非常量对象可以调用
const
成员函数和非const
成员函数。 - 非常量对象调用
const
成员函数时,this
指针类型为ClassName* const
,表示指针本身是常量,但指向的对象内容可以被修改。 - 非常量对象调用非
const
成员函数时,可以自由修改对象的状态。
(2)inline
1.特性
-
编译器提示:
- 描述:
inline
关键字提示编译器将函数尽量内联,但最终决定权在编译器。
- 描述:
-
减少函数调用开销:
- 描述:内联函数通过将函数体直接插入调用点来减少函数调用的开销,尤其对小型和频繁调用的函数有效。
-
类型检查:
- 描述:内联函数与普通函数一样支持类型检查,避免了宏定义中的类型问题。
-
inline double square(double x) { return x * x; }
-
作用域和命名空间:
- 描述:内联函数遵循 C/C++ 的作用域和命名空间规则,可以是全局或局部的。
-
inline static int increment(int x) { return x + 1; }
2.注意事项
-
递归限制:
- 描述:内联函数不能递归调用,否则会导致无限递归展开。
- 示例:
inline int factorial(int n) { return n <= 1 ? 1 : n * factorial(n - 1); // 不推荐 }
-
代码膨胀:
- 描述:过度使用内联函数可能导致代码膨胀(增大可执行文件大小)。
- 示例:
inline int largeFunction(int a, int b, int c, int d, int e) { // 大量代码 return a + b + c + d + e; }
-
编译器优化:
- 描述:编译器可能会忽略
inline
建议,尤其是函数体过大或调用频率低时。
- 描述:编译器可能会忽略
-
调试复杂性:
- 描述:内联函数的调试可能较复杂,因为在内联后,调试信息可能不完整(由于是在调用点展开,设置断点可能无效,因为内联函数在编译后可能不会保留原始的函数边界)
(3)typedef
1.特性
-
简化复杂类型:
- 描述:
typedef
可以简化复杂类型的声明,特别是在涉及指针、函数指针和结构体时。 - 示例:
typedef unsigned long int ulint; ulint num = 123456789; // num 是一个 unsigned long int 类型的变量
- 描述:
-
提高代码可读性:
- 描述:通过使用有意义的别名,可以使代码更容易理解和维护。
- 示例:
// typedef 为函数指针创建了一个别名 func_ptr typedef int (*func_ptr)(int, int); int add(int a, int b) { return a + b; } func_ptr f = add; // f 是一个指向函数 add 的函数指针
-
平台无关性:
- 描述:
typedef
可以用于定义平台无关的类型,提高代码的可移植性。 - 示例:
// 定义一个平台无关的 64 位整数类型 #ifdef _WIN32 typedef __int64 int64; #else typedef long long int int64; #endif
- 描述:
-
结合结构体使用:
- 描述:与结构体结合使用,使结构体的定义和声明更加简洁。
- 示例:
typedef struct { int x; int y; } Point; Point p = {10, 20}; // p 是一个 Point 类型的变量
2.注意事项
-
不能创建新类型:
- 描述:
typedef
只是为现有类型创建别名,而不是创建新的类型。 -
typedef int INTEGER; INTEGER a = 10; // a 是 int 类型
- 描述:
-
与宏的区别:
- 描述:
#define
也可以创建类型别名,但不进行类型检查,而typedef
进行类型检查。 - 示例:
#define UINT unsigned int typedef unsigned int UINT_T;UINT a = 10; // 宏定义,不进行类型检查 UINT_T b = 20;// typedef 定义,进行类型检查
- 描述:
-
作用域限制:
- 描述:
typedef
的作用域仅限于其定义所在的代码块或文件,而宏没有作用域限制。 - 示例:
void func() { typedef int LOCAL_INT; LOCAL_INT a = 10; // a 是 int 类型 } // LOCAL_INT 在这里是不可见的
- 描述:
(4)explicit
特性
-
防止隐式转换:
explicit
关键字修饰的构造函数不能用于隐式类型转换。- 只有显式调用时才会触发构造函数。
-
用于构造函数和转换运算符:
- 主要用于构造函数以避免不必要的隐式类型转换。
- 也可以用于转换运算符以防止隐式类型转换操作。
-
编译时检查:
- 编译器在编译时强制执行
explicit
关键字的规则,确保类型转换是显式的。
- 编译器在编译时强制执行
注意事项
-
防止意外类型转换:
- 使用
explicit
关键字可以防止意外的类型转换,确保代码行为是明确和可控的。
- 使用
-
提高代码可读性:
- 通过显式类型转换,代码中所有的类型转换都是清晰和可读的,减少了调试和维护的复杂性。
-
适用范围:
explicit
关键字在转换运算符中的使用与在构造函数中的使用类似,都是为了控制类型转换的行为。
#include <iostream>class MyClass {
public:MyClass() : value(0) {std::cout << "Default constructor called" << std::endl;}explicit MyClass(int x) : value(x) {std::cout << "Explicit int constructor called" << std::endl;}MyClass(double y) : value(static_cast<int>(y)) {std::cout << "Implicit double constructor called" << std::endl;}void display() const {std::cout << "Value: " << value << std::endl;}private:int value;
};void printValue(const MyClass& obj) {obj.display();
}int main() {MyClass obj1; // 调用默认构造函数MyClass obj2 = MyClass(5); // 显式调用 int 构造函数MyClass obj3 = 3.14; // 隐式调用 double 构造函数printValue(MyClass(10)); // 显式调用 int 构造函数printValue(2.71); // 隐式调用 double 构造函数// MyClass obj4 = 5; // 错误,隐式调用被 explicit 阻止// printValue(5); // 错误,隐式调用被 explicit 阻止return 0;
}
4.STL 容器和算法
C++ 标准模板库(STL,Standard Template Library)是一组通用的类和函数模板,提供了强大的数据结构和算法。STL 的设计极为灵活和高效,其核心组件包括容器、算法、迭代器、仿函数、配接器和配置器。它们的组合使用提供了强大的编程功能。
主要组件
-
容器(Containers):
- 定义:用于存储和管理数据的类模板。
- 常见容器:
vector
、list
、deque
、set
、map
。 - 功能:提供数据存储和管理功能,支持动态内存分配。
-
算法(Algorithms):
- 定义:一组通用的算法,用于处理容器中的数据。
- 常见算法:
sort
(排序)、search
(搜索)、copy
、transform
。 - 功能:提供常见的数据处理方法,独立于具体的容器实现。
-
迭代器(Iterators):
- 定义:用于遍历容器的类模板,提供类似指针的接口。
- 类型:输入迭代器、输出迭代器、前向迭代器、双向迭代器、随机访问迭代器。
- 功能:实现对容器的抽象访问,使算法可以独立于具体容器实现。
-
std::vector<Student> students; // 使用 std::vector<Student>::iterator std::vector<Student>::iterator it = students.begin();// 使用 auto auto it = students.begin();
-
仿函数(Functors):
- 定义:重载了
operator()
的类或类模板,允许对象像函数一样调用。 - 功能:用于定制和扩展算法的行为。
-
// 仿函数,检查是否为偶数,并记录调用次数 struct IsEven {int count = 0; // 状态:记录调用次数bool operator()(int x) {++count;return x % 2 == 0;} };
-
现代C++中的替代方案:Lambda表达式
在现代C++(C++11及以后的版本)中,Lambda表达式提供了一种简洁的方式来定义内联仿函数,并且Lambda表达式也可以捕获外部变量,实现状态保持。
- 定义:重载了
-
配接器(Adapters):
- 定义:用于修饰容器、仿函数或迭代器接口的组件。(可以看作是对容器、仿函数或迭代器的进一步抽象和封装。)
- 类型:堆栈适配器(
stack
)、队列适配器(queue
、priority_queue
)、仿函数适配器。 - 标准模板库(STL)中提供了几种常见的容器配接器:
-
堆栈适配器(stack):
- 提供堆栈(后进先出,LIFO)的功能。
- 基于其他容器(默认是
std::deque
)实现。
-
队列适配器(queue):
- 提供队列(先进先出,FIFO)的功能。
- 基于其他容器(默认是
std::deque
)实现。
-
优先队列适配器(priority_queue):
- 提供优先队列的功能,元素按照优先级排序。
- 基于
std::vector
容器实现。
-
配置器(Allocators):
- 定义:管理内存分配和释放的类模板。
- 功能:负责内存的动态分配、管理和释放。