- cpp quiz
- 1. grammar
- 1.1 c++中NULL和nullptr的区别
- 1.2 右值引用
- 1.3 const T&作为形参的时候,可以输入右值吗?
- 1.4 为什么std::thread传递引用的时候要用std::ref
- 1.5 通过指针访问和直接访问的区别是什么?
- 1.6 一般哪些函数可以使用noexcept?
- 1.7 c++怎么定义隐式转换规则
- 1.8 为什么delete之后,都会设置nullptr
- 1.9 既然如此,为什么delete不内置delete完自动设置为nullptr的操作
- 1.10 c/c++是转成汇编语言的. 那汇编语言是如何处理自定义类型的?
- 1.11 在全局构造函数中,访问其他全局或者静态变量,会发生什么事情?
- 1.12 为什么c++的成员不可以既是template又是virtual的
- 1.13 “vector”(向量)在堆上还是栈上? “vector” 中的数据在堆上还是栈上?
- 1.14 什么时候需要使用std::move()
- 2. 编译器
- 3. new feature
- 99. Exercise
- 1. grammar
cpp quiz
1. grammar
1.1 c++中NULL和nullptr的区别
-
在C语言中,NULL通常被定义为:#define NULL ((void *)0)
-
所以NULL实际上是一个空指针,如果在C语言中写入以下代码,编译是没有问题的,因为在C语言中把空指针赋给int和char指针的时候,发生了隐式类型转换,把void指针转换成了相应类型的指针
int *pi = NULL; char *pc = NULL;
- 以上代码如果使用C++编译器来编译则是会出错的,因为C++是强类型语言,void*是不能隐式转换成其他类型的指针的,所以实际上编译器提供的头文件做了相应的处理
#ifdef __cplusplus #define NULL 0 #else #define NULL ((void *)0) #endif
- 但是NULL为0的时候,会出现类型判断错误
void foo(int); void foo(char*); foo(NULL); // 调用哪个函数?
- 一个new出来的指针,被delete两次会怎么样
如果一个指针被
delete
两次,会导致未定义行为(Undefined Behavior).这是因为在第一次delete
后,该指针已经不再指向有效的内存区域,再次尝试delete
就是对无效内存的操作,这是非法的.
未定义行为可能会导致各种各样的问题,包括但不限于:
- 程序崩溃
- 数据损坏
- 难以预测和解释的行为
为了避免这种情况,你应该确保每个new
操作都有一个对应的delete
操作,并且每个delete
操作都只执行一次.在你的代码中,UniquePtr
类就是通过封装new
和delete
操作来确保资源的正确管理,避免了这种问题.
- 为什么delete之后,都会设置nullptr
在C++中,delete
一个指针后,通常会将其设置为nullptr
,主要有以下几个原因:
-
避免悬挂指针:
delete
一个指针后,该指针就变成了悬挂指针(Dangling Pointer),它不再指向有效的内存区域,但仍然保留了原来的地址值.如果你试图再次使用这个悬挂指针,就会导致未定义行为.将指针设置为nullptr
可以避免这种情况,因为nullptr
是一个特殊的指针值,表示该指针不指向任何对象. -
安全地重复
delete
:在C++中,delete
一个nullptr
是安全的,不会有任何效果.所以,如果你将一个指针设置为nullptr
,就可以安全地重复delete
它,而不会导致未定义行为. -
检查指针是否已经被
delete
:如果你将一个指针设置为nullptr
,就可以通过检查该指针是否为nullptr
来判断它是否已经被delete
.
所以,delete
一个指针后,通常会将其设置为nullptr
,以提高程序的安全性和稳定性.
1.2 右值引用
1.3 const T&作为形参的时候,可以输入右值吗?
是的,当函数的形参为 const T&
类型时,你可以传入右值。
在 C++ 中,右值引用(T&&
)主要用于移动语义和完美转发,而 const T&
可以绑定到几乎任何类型的表达式,包括临时对象(右值)。
这使得 const T&
成为一种非常灵活的形参类型,可以接受各种类型的实参。例如,你可以使用 const std::string&
类型的形参来接受字符串字面量、std::string
对象,甚至是返回 std::string
的函数的结果。
以下是一个例子:
void print(const std::string& str) {
std::cout << str << std::endl;
}
int main() {
print("Hello, world!"); // 传入字符串字面量(右值)
std::string s = "Hello, world!";
print(s); // 传入 std::string 对象(左值)
print(std::string("Hello, world!")); // 传入临时 std::string 对象(右值)
return 0;
}
在这个例子中,print
函数的形参为 const std::string&
类型,可以接受各种类型的实参。
1.4 为什么std::thread传递引用的时候要用std::ref
1.5 通过指针访问和直接访问的区别是什么?
struct Point3d{};
Point3d origin;
*pt = &origin;
origin.x=0.0; // (1)
pt->x=0.0; // (2)
(1) origin.x=0.0;
和 (2) pt->x=0.0;
这两种方式都是用来设置Point3d
对象origin
的x
成员的值为0.0
.它们的区别在于使用的方式不同,从编译器的角度来看:
-
(1)
origin.x=0.0;
这种方式是直接通过对象名和成员名来访问和修改成员的值.编译器在编译时期就能确定origin
的内存地址,然后通过偏移量找到x
成员的位置,直接在这个位置写入0.0
. -
(2)
pt->x=0.0;
这种方式是通过一个指向对象的指针来访问和修改成员的值.pt
是一个指向origin
的指针,pt->x
表示的是pt
所指向的对象的x
成员.在编译时期,编译器并不能确定pt
所指向的内存地址,它需要在运行时期读取pt
的值(也就是origin
的地址),然后通过偏移量找到x
成员的位置,再在这个位置写入0.0
.
总的来说,这两种方式的效果是相同的,它们都会设置origin
的x
成员的值为0.0
.但是从编译器的角度来看,直接访问成员的方式更简单,效率也稍微高一些,因为它不需要在运行时期读取指针的值.然而,通过指针访问成员的方式更灵活,它可以动态地访问不同的对象,这在处理数组和动态内存分配时非常有用.
在第二种情况中,pt->x=0.0;
,pt
是一个指针,它存储的是origin
对象的内存地址.编译器在编译时期并不能确定pt
所指向的具体内存地址,因为这个地址是在运行时期由操作系统分配的.因此,当你通过pt
来访问x
成员时,编译器需要在运行时期读取pt
的值(也就是origin
的地址),然后通过这个地址和x
成员的偏移量来找到x
成员的具体位置.
这就是为什么在运行期需要读取指针的值.这种机制使得指针可以动态地指向不同的对象,这在处理动态内存分配/数组和数据结构(如链表和树)时非常有用.
1.6 一般哪些函数可以使用noexcept?
在C++中,noexcept
关键字用于指定一个函数不会抛出任何异常.这对于提高代码的性能和可靠性非常有用.以下是一些常见的可以使用noexcept
的函数:
- 析构函数:析构函数通常不应抛出任何异常,因为如果在析构函数中抛出异常,可能会导致程序的未定义行为.
- 移动构造函数和移动赋值操作符:这些函数通常应该被标记为
noexcept
,因为它们通常只涉及指针和基本类型的转移,不应抛出异常.此外,标记为noexcept
的移动操作可以被标准库容器如std::vector
更有效地使用. - 交换函数:交换函数(如
std::swap
)通常应该被标记为noexcept
,因为它们通常只涉及指针和基本类型的转移,不应抛出异常. - 其他不会抛出异常的函数:如果你知道一个函数不会抛出异常,你应该使用
noexcept
关键字.这可以帮助编译器优化代码,并向其他开发者明确表明该函数不会抛出异常. 在你提供的代码中,移动构造函数和移动赋值操作符被标记为noexcept
,这是因为它们只涉及指针和基本类型的转移,不应抛出异常.
1.7 c++怎么定义隐式转换规则
在C++中,隐式转换(也称为自动类型转换)是由编译器自动进行的.这些转换规则是由C++语言规定的,例如从int到double的转换,或者从派生类到基类的转换.
然而,你也可以为你的自定义类型定义隐式转换规则.这可以通过定义转换函数来实现.转换函数是一种特殊的成员函数,它可以将一个类的对象转换为其他类型的对象.
例如,假设你有一个名为MyClass
的类,你想让它可以隐式转换为int
类型.你可以在MyClass
中定义一个名为operator int()
的转换函数:
class MyClass {
public:
operator int() const {
// 在这里返回一个int值
return 0;
}
};
然后你就可以这样使用它:
MyClass obj;
int i = obj; // 隐式转换
需要注意的是,虽然隐式转换看起来很方便,但它也可能导致一些意想不到的问题,因为它可能在你不知情的情况下发生.因此,一般建议尽量避免使用隐式转换,或者至少要确保你清楚地知道它何时会发生以及它的效果是什么.
1.8 为什么delete之后,都会设置nullptr
在C++中,delete
一个指针后,通常会将其设置为nullptr
,主要有以下几个原因:
-
避免悬挂指针:
delete
一个指针后,该指针就变成了悬挂指针(Dangling Pointer),它不再指向有效的内存区域,但仍然保留了原来的地址值.如果你试图再次使用这个悬挂指针,就会导致未定义行为.将指针设置为nullptr
可以避免这种情况,因为nullptr
是一个特殊的指针值,表示该指针不指向任何对象. -
安全地重复
delete
:在C++中,delete
一个nullptr
是安全的,不会有任何效果.所以,如果你将一个指针设置为nullptr
,就可以安全地重复delete
它,而不会导致未定义行为. -
检查指针是否已经被
delete
:如果你将一个指针设置为nullptr
,就可以通过检查该指针是否为nullptr
来判断它是否已经被delete
.
所以,delete
一个指针后,通常会将其设置为nullptr
,以提高程序的安全性和稳定性.
1.9 既然如此,为什么delete不内置delete完自动设置为nullptr的操作
delete
操作符在C++中的设计是不会自动将指针设置为nullptr
的,这主要是由于以下几个原因:
-
兼容性:C++是C语言的扩展,为了保持与C语言的兼容性,
delete
操作符的行为与C语言中的free
函数类似,都不会自动将指针设置为nullptr
. -
灵活性:在某些情况下,程序员可能不希望
delete
后自动将指针设置为nullptr
.例如,如果一个指针被多个对象共享,delete
其中一个对象后,其他对象可能仍需要使用该指针.如果delete
自动将指针设置为nullptr
,就会破坏这种共享关系. -
性能:自动将指针设置为
nullptr
需要额外的操作,这可能会对性能产生影响.在性能敏感的程序中,这可能是一个问题.
因此,C++的设计者选择让delete
操作符的行为更简单/更直接,不自动将指针设置为nullptr
.这样,程序员可以根据需要自行决定是否将指针设置为nullptr
.
1.10 c/c++是转成汇编语言的. 那汇编语言是如何处理自定义类型的?
汇编语言本身并没有自定义类型的概念,它只知道字节和地址.当你在C++中定义一个类型(例如,一个结构体或类),编译器会根据你的定义来决定如何在内存中布局数据,以及如何生成对应的汇编代码来访问这些数据.
例如,如果你定义了一个包含两个整数的结构体:
struct MyStruct {
int a;
int b;
};
编译器会知道这个结构体需要8个字节的空间(在大多数现代系统上,一个int
需要4个字节).当你创建一个MyStruct
对象并访问它的成员时,编译器会生成对应的汇编代码来读取或写入这些地址.
但是,这个过程是由编译器完成的,汇编语言本身并不知道MyStruct
这个类型,它只知道如何处理字节和地址.这就是为什么在汇编语言中,你需要手动管理所有的内存布局和数据访问.
1.11 在全局构造函数中,访问其他全局或者静态变量,会发生什么事情?
在C++中,全局构造函数通常指的是全局对象或静态对象的构造函数。这些对象在程序开始执行main函数之前就已经被初始化。
问题在于,C++标准并没有规定全局对象和静态对象的初始化顺序。也就是说,如果一个全局对象或静态对象的构造函数试图访问另一个全局对象或静态对象,那么这个对象可能还没有被初始化,这就会导致不可预知的结果。
例如,假设你有两个全局对象A和B,A的构造函数试图访问B。如果B在A之前被初始化,那么一切都会正常工作。但是,如果A在B之前被初始化,那么A的构造函数就会访问一个未初始化的对象B,这可能会导致程序崩溃或其他错误。
为了避免这种问题,一种常见的解决方案是使用函数内的静态对象,因为这些对象的初始化顺序是确定的:它们在第一次被访问时被初始化。另一种解决方案是避免在全局构造函数中访问其他全局对象或静态对象。
1.12 为什么c++的成员不可以既是template又是virtual的
在C++中,成员函数不能同时是模板(template)和虚函数(virtual)。这是因为这两种特性的工作方式在根本上是冲突的。
-
模板函数:模板函数在编译时生成。当你使用一个特定的类型调用模板函数时,编译器会生成一个针对该类型的特化版本。因此,模板函数的所有可能版本必须在编译时已知。
-
虚函数:虚函数在运行时解析。当你通过基类指针或引用调用虚函数时,程序会在运行时查找适当的函数版本。因此,虚函数的所有可能版本必须在运行时可用。
如果一个函数既是模板函数又是虚函数,那么它在编译时和运行时都需要解析,这是不可能的。因此,C++不允许函数同时是模板函数和虚函数。
1.13 “vector”(向量)在堆上还是栈上? “vector” 中的数据在堆上还是栈上?
在C++中,std::vector
对象本身通常在栈上创建,但其管理的数据(即元素)实际上是在堆上分配的。
当你创建一个std::vector
对象时,它会在栈上分配一小块内存来存储元数据(如元素数量和容量),以及一个指向堆上数据的指针。当你向std::vector
添加元素时,它会在堆上分配更多的内存来存储这些元素。
因此,可以说std::vector
的”结构”在栈上,而其”数据”在堆上。
1.14 什么时候需要使用std::move()
在C++中,std::move()
通常在以下情况下使用:
-
当你想避免不必要的拷贝时:如果你有一个大对象,并且你想将它传递给另一个对象,但你不再需要原始对象,那么你可以使用
std::move()
来避免拷贝。这将触发移动构造函数或移动赋值操作符(如果已定义),这通常比拷贝构造函数或拷贝赋值操作符更有效率。 -
当你想返回局部对象时:如果你的函数需要返回一个局部对象,并且你想避免返回值优化(RVO)或命名返回值优化(NRVO)未发生时的拷贝,那么你可以使用
std::move()
。 -
当你需要将左值转换为右值时:有些函数或方法需要右值引用参数,以便它们可以获取对象的所有权。在这种情况下,你可以使用
std::move()
来将左值转换为右值。
请注意,使用std::move()
后,原始对象将处于有效但未定义的状态,你不能再使用它,除非你已经重新赋值或重新初始化它。
2. 编译器
2.1 引用是怎么实现的?
在 C++ 中,当我们说”引用”时,我们通常不会说它被”拷贝”,因为引用本身并不占用任何存储空间,它只是一个别名.当你将一个对象作为引用传递给函数时,实际上并没有发生任何拷贝操作.函数接收的是对原始对象的直接引用,而不是任何形式的拷贝.
然而,从底层实现的角度来看,引用在某种程度上可以被视为一个常量指针.当你创建一个引用并将其初始化为一个对象时,编译器会在底层创建一个指向该对象的常量指针.这个指针在初始化后就不能改变,它将一直指向初始化时的那个对象.因此,当你通过引用访问对象时,实际上是通过这个常量指针访问的.
但是,这并不意味着引用是通过拷贝指针来实现的.引用的实现细节可能因编译器和平台的不同而不同,但是从语言的角度来看,引用并不涉及拷贝操作.
2.2 c++的内存空间
-
代码段(Text Segment):也被称为文本区域,这部分空间存储程序的机器代码.
-
数据段(Data Segment):这部分空间存储程序中的全局变量和静态变量.
-
堆(Heap):堆是用于动态内存分配的区域,如C++中的new操作符和C中的malloc函数分配的内存.
-
栈(Stack):栈用于存储局部变量和函数调用的信息.每当一个函数被调用时,一个新的栈帧就会被压入栈中,这个栈帧包含了函数的局部变量和返回地址.
-
常量段(Constant Segment):这部分空间存储程序中的常量值.
-
BSS段(Block Started by Symbol):这部分空间存储程序中未初始化的全局变量和静态变量.
在多线程环境中,每个线程都有自己的栈空间,但所有线程共享同一个堆空间.
需要注意的是,这些内存划分是逻辑上的划分,物理内存的实际布局可能会因操作系统和硬件的不同而有所不同.
2.2.1 c++的对象内存空间
- 一个类的成员函数并不是存储在每个类的实例中的.成员函数只有一份代码,它们存储在内存的代码段中,而不是每个对象的内存空间中.
- 每个对象中存储的是数据成员,而成员函数通过隐式参数this来访问这些数据成员.
- 除了数据成员,还有vptr
2.2.2 常量段和数据段为什么要分开?
常量段和数据段被分开主要是出于以下几个原因:
-
保护:常量段中的数据是只读的,不应被修改.将常量段和数据段分开可以让操作系统设置不同的内存保护权限,防止程序错误地修改常量数据.
-
优化:编译器和链接器可以对常量数据进行优化.例如,如果一个常量在程序中被多次使用,编译器可以只在常量段中存储一份该常量的副本.
-
内存管理:在某些系统中,常量段可能被映射到只读的物理内存或者ROM中,这样可以节省可读写的物理内存.
总的来说,将常量段和数据段分开可以提高程序的安全性和效率,同时也有助于内存管理.
2.2.3 bss段和数据段为什么要分开?
BSS段和数据段被分开主要是出于以下几个原因:
-
初始化:数据段中的变量在程序开始运行之前就已经被初始化了,而BSS段中的变量则在程序开始运行时被自动初始化为0.将这两种变量分开可以让操作系统和编译器更有效地处理它们.
-
存储优化:在程序的可执行文件中,BSS段的变量不需要占用任何实际的存储空间,只需要记录它们的位置和大小.这是因为BSS段的变量在程序开始运行时都会被初始化为0,所以没有必要在可执行文件中为它们存储实际的数据.这可以减小可执行文件的大小,节省磁盘空间.
-
内存管理:将BSS段和数据段分开可以让操作系统更有效地管理内存.例如,操作系统可以将数据段映射到只读的物理内存中,而将BSS段映射到可读写的物理内存中.
总的来说,将BSS段和数据段分开可以提高程序的效率,减小可执行文件的大小,同时也有助于内存管理.
2.2.3. 多线程的内存空间是如何管理的
多线程的内存空间是怎么管理的? 在C++中,每个线程都有自己的栈空间,这个栈空间是在线程创建时由操作系统自动分配的.线程的栈空间通常用于存储线程的局部变量和函数调用的上下文信息.
线程的栈空间通常位于进程的虚拟地址空间中,每个线程的栈空间都是独立的,互不干扰.线程的栈空间大小通常有一个默认值,但是在某些系统和编程环境中,你可以在创建线程时指定栈空间的大小.
需要注意的是,虽然每个线程都有自己的栈空间,但是所有线程共享同一个进程的堆空间.这意味着线程可以通过堆来共享数据,但是需要注意同步和数据一致性的问题.
2.3 C++多态的实现
3. new feature
3.1 c++的向量化是什么意思?
向量化在 C++ 中通常指的是一种特殊的编程技术,它可以显著提高处理大量数据时的性能。这种技术利用了现代 CPU 的向量运算指令(如 SIMD 指令),这些指令可以一次处理多个数据。
例如,假设你有两个包含四个元素的数组,你想要将它们相加。在传统的方式中,你可能会写一个循环,依次处理每个元素。但是,如果使用向量化,你可以一次处理所有四个元素。
在 C++ 中,你可以使用特定的库(如 Intel 的 MKL 库或 C++17 的 Parallel STL)来进行向量化操作。此外,许多现代编译器也能自动向量化一些循环。
需要注意的是,虽然向量化可以提高性能,但并不是所有的算法和数据都适合向量化。例如,如果数据量太小,或者算法需要大量的条件判断,那么向量化可能不会带来太大的性能提升。
99. Exercise
int main() {
int a[2][5] = { {1, 2, 3, 4, 5}, {1, 2, 3, 4, 5} };
cout << a << endl;
cout << *a << endl;
cout << *a[0] << endl;
return 0;
}
for (unsigned int i = 10; i >= 0; --i) { ... }
std::vector<int> vec;
vec.push_back(1);
for (auto idx = vec.size(); idx >= 0; idx--) {
cout << "===== \n";
}
void erase(std::vector<int> &vec, int a) {
for (auto iter = vec.begin(); iter != vec.end();) { // 这个正确
if (*iter == a) {
iter = vec.erase(iter);
} else {
++iter;
}
}
for (auto iter = vec.begin(); iter != vec.end(); ++iter) { // error
if (*iter == a) {
vec.erase(iter); // error
}
}
}