二维数组的指针与解指针
1 |
|
1 |
|
二维数组a[10][20]
中,a+1
、a[1]
、&a[0]+1
表示第一层数组元素(a[0]、a[1]...)的地址,*a+1
、a[0][1]
、&a[0][0]+1
表示第二层数组元素(a[0][0]、a[0][1]...)的地址,&a
表示二维数组本身的地址。
如果将该二维数组赋值给指针b
,则b
表示二维数组本身的地址,&b
表示指针本身的地址。
怎么样禁止产生堆对象?(静态分配)
在C++中,如果你想禁止某个类生成堆对象(即只能在栈上创建该对象),你可以通过将类的 operator new
和 operator delete
重载并将它们声明为私有或删除(= delete
)。这会阻止在堆上使用 new
操作符创建对象。
以下是一个简单的示例:
方式 1: 重载并私有化 operator new
和 operator delete
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21class MyClass {
public:
// 公共构造函数和析构函数
MyClass() {}
~MyClass() {}
private:
// 私有化的 new 和 delete 操作符,禁止堆分配
void* operator new(size_t size) {
return ::operator new(size);
}
void operator delete(void* ptr) {
::operator delete(ptr);
}
};
int main() {
MyClass obj; // 正确,栈上分配
MyClass* pObj = new MyClass(); // 错误,无法在堆上分配对象
return 0;
}
在这个示例中,operator new
和 operator delete
被声明为私有,这意味着在类的外部无法通过 new
操作符在堆上创建对象。由于 main
函数无法访问私有成员,尝试在堆上创建对象时会导致编译错误。
方式 2: 删除 operator new
和 operator delete
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16class MyClass {
public:
// 公共构造函数和析构函数
MyClass() {}
~MyClass() {}
// 禁止在堆上创建对象
void* operator new(size_t) = delete;
void operator delete(void*) = delete;
};
int main() {
MyClass obj; // 正确,栈上分配
MyClass* pObj = new MyClass(); // 错误,无法在堆上分配对象
return 0;
}
在这个示例中,我们将 operator new
和 operator delete
标记为 = delete
,这会完全禁止在堆上创建和销毁对象。这样做的效果是,当你试图使用 new
操作符在堆上分配对象时,编译器会直接给出错误提示。
通过这些方法,你可以确保某个类只能在栈上创建,而无法在堆上分配。
怎么样禁止产生栈对象?(动态分配)
在 C++ 中,可以通过将类的构造函数或析构函数声明为 private
或 protected
来禁止从栈上创建该类的对象。这样,外部代码无法直接在栈上声明类的对象,但可以通过允许堆上分配对象来控制对象的生命周期。
方法 1: 使用私有构造函数和析构函数
通过将类的构造函数和析构函数声明为 private
或 protected
,可以防止在栈上创建对象。用户可以通过静态工厂方法或动态内存分配(使用 new
)来创建对象。
1 |
|
方法 2: 使用 protected
构造函数和析构函数
使用 protected
而不是 private
可以让类的派生类(子类)继承并创建对象,但仍然阻止从栈上直接创建基类对象。
1 |
|
对象复用与零拷贝
1 |
|
1 |
|
嗯?怎么和说的不一样?最后emplace_back怎么调用了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
25
26
27
28
29
30
31
32int main()
{
vector<Person> people;
Person p1("Alice", 30);
// 使用 emplace_back 直接原地构造对象
cout<<"3.emplace_back:"<<endl;
people.emplace_back("Charlie", 20); // 直接构造,无需拷贝或移动
// 使用 push_back 插入对象
cout<<"1.push_back:"<<endl;
people.push_back(p1); // 调用拷贝构造函数
// 使用 push_back 插入对象
cout<<"2.push_back:"<<endl;
people.push_back(Person("Bob", 25)); // 调用移动构造函数,因为临时对象被传递
return 0;
}
//输出结果:
// I have been constructed
//3.emplace_back:
// I have been constructed
//1.push_back:
// I have been copy constructed
// I have been copy constructed
//2.push_back:
// I have been constructed
// I have been moved
// I have been copy constructed
// I have been copy constructed
1 |
|
我们可以发现emplace_back确实没有调用拷贝/移动构造函数,只需使用普通构造函数。那么上面这个问题是为什么?
输出和预期不符,主要原因在于 std::vector
的动态扩容行为。具体来说,当 std::vector
需要扩展其内部存储空间时,它会重新分配更大的存储空间,并将已有的元素拷贝或移动到新的存储空间中。因此,发生了多次拷贝构造或移动构造,而这些额外的操作就是导致输出和预期不同的原因。
std::vector
在插入元素时会动态调整其容量(capacity
)。当 size
超过当前的 capacity
时,vector
会重新分配更大的内存,并将旧的元素拷贝到新的位置。这就是为什么你看到多次调用拷贝构造函数的原因,尤其是在 emplace_back
和 push_back
操作之间。
默认情况下,vector
的扩展通常是按一定倍数增加容量,比如 2 倍。当 vector
需要重新分配存储空间时,会调用拷贝构造函数或移动构造函数来将现有元素复制或移动到新的内存中。
如果你想避免额外的拷贝或移动构造操作,可以在开始时手动预留足够的空间,使用 vector::reserve
函数。 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
30int main()
{
vector<Person> people;
people.reserve(3); // 预留3个元素的空间,避免扩容
Person p1("Alice", 30);
// 使用 push_back 插入对象
cout << "1.push_back:" << endl;
people.push_back(p1); // 调用拷贝构造函数
// 使用 push_back 插入对象
cout << "2.push_back:" << endl;
people.push_back(Person("Bob", 25)); // 调用移动构造函数
// 使用 emplace_back 直接原地构造对象
cout << "3.emplace_back:" << endl;
people.emplace_back("Charlie", 20); // 直接构造,无需拷贝或移动
return 0;
}
//输出结果:
// I have been constructed
//1.push_back:
// I have been copy constructed
//2.push_back:
// I have been constructed
// I have been moved
//3.emplace_back:
// I have been constructed
传参顺序
1 |
|
移动构造函数
移动构造函数在 C++ 中用于实现对象的“移动语义”,这使得资源(如内存或文件句柄)从一个对象转移到另一个对象,而无需复制。移动构造函数通过使用 std::move
来获取一个右值引用,从而避免不必要的资源复制操作,提高程序的性能。
下面是一个简单的例子,展示了如何实现和使用移动构造函数:
1 |
|
代码解释:
- 构造函数:创建一个指定大小的动态数组,并初始化
size
和data
。
- 构造函数:创建一个指定大小的动态数组,并初始化
- 拷贝构造函数:深拷贝另一个对象的
data
,创建新的内存块以避免资源共享。
- 拷贝构造函数:深拷贝另一个对象的
- 移动构造函数:
- 使用右值引用
MyVector&&
接收一个即将销毁的对象other
。 - 将
other
的资源(data
和size
)转移给当前对象。 - 将
other
的指针设为nullptr
,并将size
设为 0,防止other
在销毁时释放已转移的资源。
- 析构函数:在对象销毁时释放动态分配的内存。
std::move
:将vec1
转换为右值,以便调用移动构造函数,而不是拷贝构造函数。
输出结果:
1 |
|
- 创建
vec1
时,调用了构造函数,分配了大小为 5 的数组。 - 通过
std::move
,vec1
的资源(内存)被转移到了vec2
,因此调用了移动构造函数。 - 移动后,
vec1
的size
变为 0,data
变为nullptr
,确保移动语义的正确性。 - 在程序结束时,分别调用
vec1
和vec2
的析构函数,清理资源。
基本数据类型大小
以下是常见基本数据类型在 32 位和 64 位系统上的大小对比:
数据类型 | 32 位系统大小 (bytes) | 64 位系统大小 (bytes) |
---|---|---|
char |
1 | 1 |
short |
2 | 2 |
int |
4 | 4 |
long |
4 | 8 |
long long |
8 | 8 |
float |
4 | 4 |
double |
8 | 8 |
long double |
12 (或 16) | 16 |
pointer |
4 | 8 |
结构体大小
一、结构体对齐规则首先要看有没有用#pragma pack
宏声明,这个宏可以改变对齐规则,有宏定义的情况下结构体的自身宽度就是按照这个宏声明的和实际数据类型中最大值较小的那个来决定,所有内存都按照这个宽度去布局,#pragma pack
参数只能是 '1', '2', '4', '8', or '16'。
二、在没有#pragma pack
这个宏的声明下,遵循下面三个原则:
- 第一个成员的首地址为0;
- 每个成员的首地址是自身大小的整数倍;
- 结构体的总大小,为其成员中所含最大类型的整数倍。
1 |
|
#pragma pack
加了 #pragma pack
会怎样? 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#include <iostream>
#include <stddef.h>
using namespace std;
int main()
{
#pragma pack(1)
struct S1
{
int x; // 0(4)
char y; // 4(1)
int z; // 5(4)
double a; // 9(8)
}; // 17
cout << offsetof(S1, x) << endl;
cout << offsetof(S1, y) << endl;
cout << offsetof(S1, z) << endl;
cout << offsetof(S1, a) << endl;
cout << sizeof(S1) << endl;
#pragma pack(2)
struct S2
{
int x; // 0(4)
char y; // 4(1+1)
int z; // 6(4)
double a; // 10(8)
}; // 18
cout << offsetof(S2, x) << endl;
cout << offsetof(S2, y) << endl;
cout << offsetof(S2, z) << endl;
cout << offsetof(S2, a) << endl;
cout << sizeof(S2) << endl;
#pragma pack(16)
struct S3
{
int x; // 0
char y; // 4
int z; // 8
double a; // 16
}; // 24
cout << offsetof(S3, x) << endl;
cout << offsetof(S3, y) << endl;
cout << offsetof(S3, z) << endl;
cout << offsetof(S3, a) << endl;
cout << sizeof(S3) << endl;
return 0;
}#pragma pack
对齐规则总结:
- 默认对齐(没有
#pragma pack
):- 默认情况下,编译器按照结构体成员类型的大小(也叫“自然对齐”)进行对齐。每个成员在结构体中的地址必须是该成员大小的倍数。
- 编译器会插入填充字节以确保每个成员在正确的对齐边界上。
#pragma pack(n)
指定对齐:- 使用
#pragma pack(n)
,可以显式指定一个对齐大小n
,告诉编译器所有成员按照n
字节对齐。 - 当成员的自然对齐要求大于
n
时,成员会按n
字节对齐(即强制对齐),如果自然对齐小于或等于n
,则按成员的自然对齐进行对齐。
- 使用
- 对齐的行为:
- 成员对齐:
#pragma pack(n)
会限制结构体中每个成员的对齐方式,所有成员的起始地址都要对齐到n
或该成员类型的自然对齐值,取较小者。例如,如果n
为 2,int
类型的成员将按 2 字节对齐(虽然它通常按 4 字节对齐)。 - 结构体大小对齐:结构体的总大小通常也会对齐到
n
字节或最大成员的自然对齐值的倍数。
- 成员对齐:
位域
除此之外,还有一种特殊情况,叫位域: 1
2
3
4
5
6
7
8
9
10struct S1
{
char a : 7; // 7 bits
int b : 11; // 11 bits
int c : 4; // 4 bits
int d : 10; // 10 bits
char index; // 1 byte (8 bits)
}; // 8 byte
cout << offsetof(S1, a) << endl; // offsetof 在位域情况下编译报错!
cout << sizeof(S1) << endl;struct test
中,使用了 位域(bit field) 来定义结构体成员。位域允许你定义精确的位数来存储某个成员。理解位域的对齐和存储方式稍微复杂一些,因为它涉及到位的排列和字节对齐。让我们逐一分析每个成员的存储方式和对齐情况。
位域的存储规则: - 位域的存储依赖于具体的编译器实现,但通常来说,位域会按照其底层类型的大小进行分配和存储。例如,int
通常占 4 字节(32 位),位域会在这个范围内尽可能地放置多个字段。 - 如果一个字段不能放入当前的剩余位域空间中,那么它会被存储在新的机器字中,新的机器字会遵守该字段类型的对齐规则。
逐个字段分析:
char a : 7
:char a
占用 7 位,但由于char
是 1 字节(8 位),剩下的 1 位可能留作填充位或用于存储后续位域。- 假设编译器允许多个位域共享同一个字节,因此
a
占据结构体的前 7 位。
int b : 11
:int b
需要 11 位。由于char a : 7
已占用了 1 字节中的 7 位,b
的前 1 位将填充剩下的第一个字节,后续的 10 位将跨越到下一个 32 位的int
单元。- 也就是说,
b
的前 1 位放在第一个字节的最后一位,剩余的 10 位占用第二个字节的前 10 位。
int c : 4
:c
需要 4 位。剩下的b
已经占用了 11 位,因此c
可以紧接着在剩余的b
后面的位上使用。这意味着c
将占据剩余的 4 位,而不需要新开辟一个新的机器字。
int d : 10
:d
需要 10 位。由于前面已经使用了11 + 4 = 15
位,剩下的空间不足以存储d
。因此,d
将被存储在新的int
单元中,跨越两个字节。
char index
:index
是一个完整的char
,占用 1 字节的存储空间,按 1 字节对齐。
总体布局和对齐:
- 位域成员
a
,b
,c
, 和d
将按照上面分析的位来分配,总共使用了 7 + 11 + 4 + 10 = 32 位(4 字节)来存储这些成员。 - 位域成员会被打包在一起,不会跨越超过其类型所能表示的范围。例如,
int
位域成员不会跨越 32 位的机器字边界。 - 由于
index
是一个常规的char
成员,它将按照 1 字节对齐,存储在新的字节空间中。
计算大小:
假设 int
是 4 字节,char
是 1 字节,并且结构体的对齐按 4 字节来进行:
- 位域部分:
a + b + c + d
总共占 32 位,正好等于 4 字节(1 个int
的大小)。 index
成员:char index
占用 1 字节。
因此,结构体的总大小为: - 4 字节(位域部分) - 1 字节(char index
) - 再加上对齐需要的 3 个填充字节(成员int为4字节,为了让整个结构体的大小符合 4 字节对齐规则)。
最终结构体的总大小为 8 字节。
静态类型、动态类型、静态绑定和动态绑定
在 C++ 中,静态类型、动态类型、静态绑定和动态绑定是重要的概念,用于理解如何解析函数调用和类型信息。以下是这几个概念的解释和示例:
1. 静态类型(Static Type)
静态类型是编译时确定的类型。它是指在编译过程中变量的声明类型。编译器根据静态类型来检查代码的正确性。
示例: 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15class Base {
public:
void show() { std::cout << "Base show\n"; }
};
class Derived : public Base {
public:
void show() { std::cout << "Derived show\n"; }
};
int main() {
Base* basePtr; // 静态类型是 Base*
Derived derivedObj;
basePtr = &derivedObj; // 静态类型为 Base*,动态类型为 Derived
}basePtr
的静态类型是 Base*
,它在编译时确定。
2. 动态类型(Dynamic Type)
动态类型是在运行时确定的类型。它是对象的实际类型,而不是指向对象的指针或引用的类型。
示例: 1
2
3
4
5int main() {
Base* basePtr = new Derived(); // 静态类型是 Base*,动态类型是 Derived
basePtr->show(); // 调用的是 Derived::show,因为实际对象是 Derived
delete basePtr;
}basePtr
的静态类型是 Base*
,但动态类型是 Derived
。因此,basePtr->show()
调用的是 Derived
类中的 show
函数。
3. 静态绑定(Static Binding)
静态绑定(或称为早期绑定)是编译时确定函数的调用。函数调用的具体实现由编译器在编译阶段决定。
示例: 1
2
3
4
5
6
7
8
9class Example {
public:
void display() { std::cout << "Static binding\n"; }
};
int main() {
Example obj;
obj.display(); // 这里的 display() 调用在编译时决定,属于静态绑定
}obj.display()
是静态绑定,因为编译器在编译时决定了 display
方法的调用。
4. 动态绑定(Dynamic Binding)
动态绑定(或称为晚期绑定)是在运行时确定函数的调用。它通常用于虚函数(通过虚函数表 vtable)来实现多态性。
示例: 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15class Base {
public:
virtual void show() { std::cout << "Base show\n"; }
};
class Derived : public Base {
public:
void show() override { std::cout << "Derived show\n"; }
};
int main() {
Base* basePtr = new Derived(); // 静态类型是 Base*,动态类型是 Derived
basePtr->show(); // 动态绑定,实际调用的是 Derived::show()
delete basePtr;
}basePtr->show()
是动态绑定,因为实际的 show
方法在运行时通过 Derived
类的虚函数表来确定。
总结: - 静态类型:编译时确定的类型(指针或引用的声明类型)。 - 动态类型:运行时确定的类型(对象的实际类型)。 - 静态绑定:编译时确定函数调用。 - 动态绑定:运行时确定函数调用,通常通过虚函数实现多态性。
这些概念在 C++ 的面向对象编程中尤为重要,有助于理解如何进行函数调用、实现多态以及管理类型信息。
虚函数和默认参数
1 |
|
这个代码的行为展示了虚函数和默认参数在 C++ 中的工作原理。关键问题出现在:
1 |
|
当调用 pe->func();
时,虽然函数调用的是派生类 F
的 func()
函数,但默认参数的值却使用了基类 E
中的默认值 0
。这看似反常的行为其实是由 C++ 默认参数的机制造成的。
关键点: 1. 虚函数调用: - C++ 中的虚函数调用通过虚函数表(vtable) 实现。在运行时,调用的函数是根据对象的动态类型决定的。因此,pe
是 E*
类型的指针,但它指向的是 F
类型的对象,所以调用的函数是 F::func()
(派生类的重写函数)。
- 默认参数:
- C++ 默认参数的解析是在编译时完成的,而不是在运行时。默认参数是编译器在解析函数调用时直接替换的。因此,默认参数的值取决于调用时编译器看到的函数签名。
- 在
pe->func()
这一行代码中,虽然调用的是F::func()
,但编译器在解析pe
时,看到的是E*
类型指针,因此它使用了基类E
中的默认参数0
。
为什么发生这种情况?
- 对于
pf->func();
,编译器知道pf
是F*
类型的指针,所以使用F::func()
的默认参数1
。 - 对于
pe->func();
,编译器认为pe
是E*
类型的指针,因此它使用了E::func()
的默认参数0
,尽管最终在运行时调用的是F::func()
。
解决方案:
要避免这种令人困惑的行为,有几种方式可以选择:
- 避免在虚函数中使用默认参数:
- 默认参数在虚函数中容易引发混淆,因为参数是在编译时绑定,而虚函数是在运行时决定调用的。最好在虚函数的参数中避免默认值,显式传递参数。
- 将默认参数设置为一致:
- 如果一定要使用默认参数,确保在基类和派生类中都使用相同的默认值。
修改代码避免问题的示例:
将基类和派生类中的默认参数值保持一致可以避免混淆:
1 |
|
这样,调用 pe->func();
时,默认参数始终是 1
,结果一致。
总结:
- 虚函数的决策是运行时基于对象的动态类型进行的。
- 默认参数的决策是编译时基于指针或引用的静态类型进行的。
- 在虚函数中使用默认参数容易引发不一致的行为,因此应尽量避免。