迈向 c++语言律师之路
迈向 c++语言律师之路
1. trivial grammar
1.1 attribute
[[deprecated("This function is deprecated. Use newFunction() instead.")]]
void oldFunction() {
std::cout << "This is the old function." << std::endl;
}
[[nodiscard]] int calculate() { return 42; }
这段代码展示了 C++17 标准中引入的两个新属性:[[deprecated]]
和[[nodiscard]]
。
-
[[deprecated]]
属性用于标记已经被废弃的函数。当你试图使用被[[deprecated]]
标记的函数时,编译器会生成一个警告,告诉你这个函数已经被废弃,你应该使用其他函数代替。在这个例子中,oldFunction
函数被标记为废弃,所以在main
函数中调用oldFunction
时,编译器会生成一个警告。 -
[[nodiscard]]
属性用于标记那些返回值不应该被忽略的函数。如果你调用了被[[nodiscard]]
标记的函数,但没有使用它的返回值,编译器会生成一个警告。在这个例子中,calculate
函数被标记为[[nodiscard]]
,所以在main
函数中调用calculate
但没有使用它的返回值时,编译器会生成一个警告。
除了[[deprecated]]
和[[nodiscard]]
,C++还有其他的属性,如[[maybe_unused]]
(用于标记可能未被使用的变量,以避免编译器生成未使用变量的警告)、[[likely]]
和[[unlikely]]
(用于给编译器提供分支预测的提示,这两个属性在 C++20 中引入)等。
1.2 Ref-qualified member functions
struct Bar {
// ...
};
struct Foo {
Bar getBar() & { return bar; }
Bar getBar() const& { return bar; }
Bar getBar() && { return std::move(bar); }
Bar getBar() const && { ...; }
private:
Bar bar;
};
Foo foo{};
Bar bar = foo.getBar(); // foo is left value, thus calls `Bar getBar() &`
const Foo foo2{};
Bar bar2 = foo2.getBar(); // foo2 is const left value , thus calls `Bar Foo::getBar() const&`
// it's all right value
Foo{}.getBar();
std::move(foo).getBar();
std::move(foo2).getBar();
1.3 placement new
placement new 是 C++ 中的一种特殊的 new 运算符,用于在指定的内存位置上构造对象,而不分配新的内存。它的意义在于提供了对内存管理的精细控制,允许程序员在预先分配的内存块上构造对象。这在某些高性能、内存受限或需要自定义内存管理的场景中非常有用。
#include <iostream>
#include <string>
union ComplexData {
int intValue;
float floatValue;
std::string strValue;
ComplexData() {}
~ComplexData() {}
};
int main() {
ComplexData data;
new (&data.strValue) std::string("Hello, Complex Union");
std::cout << "data.strValue: " << data.strValue << std::endl;
data.strValue.std::string::~string();
return 0;
}
1.4 尾回归类型
/*
1. **返回类型依赖于参数**
当返回类型需要根据函数参数推导时,尾置返回类型可以访问参数列表中的类型。最典型的例子是模板函数中使用`auto`结合`decltype`:
这里,`decltype(t + u)`需要知道`t`和`u`的类型,而传统返回类型语法无法在函数名前访问参数列表。
*/
template <typename T, typename U>
auto add(T t, U u) -> decltype(t + u) {
return t + u;
}
/*
2. **复杂返回类型简化可读性**
对于复杂的返回类型(如嵌套模板或函数指针),尾置语法可以使函数头部更清晰:
*/
auto createAdder() -> std::function<int(int, int)> {
return [](int a, int b) { return a + b; };
}
/*
3. **lambda表达式的显式返回类型**
当lambda表达式的返回类型需要显式指定时,必须使用尾置语法:
*/
auto lambda = [](int x) -> double { return static_cast<double>(x) * 2.5; };
/*
4. **元编程与类型推导**
在模板元编程中,尾置返回类型常用于结合`decltype`进行复杂的类型推导:
*/
template <typename Container>
auto getElement(Container& c, size_t index) -> decltype(c[index]) {
return c[index];
}
1.5 ADL
#include <iostream>
/*
模板编程通过类型可以找到对应的函数(如不同命名空间下的,这个就叫做ADL.
下面这个例子展示了如何通过模板编程和ADL,使得模板函数能够在不知道具体类型的情况下,利用参数的类型在相应的命名空间中查找相应的函数。
*/
namespace MyNamespace {
struct MyType {};
void someFunction(MyType myArg) {
std::cout << "Function in MyNamespace called" << '\n';
}
}
template <typename T>
void myTemplateFunction(T arg) {
someFunction(arg); // 依赖于ADL查找someFunction
}
int main() {
MyNamespace::MyType obj;
myTemplateFunction(obj); // 调用myTemplateFunction
return 0;
}
1.6 向量化是什么意思?
向量化的特性需要编译器和 CPU 都支持吗,让我们先来简单的了解一下向量化是如何工作的。假设我们有一个非常大的vector
。简单的实现可以写成如下的方式:
std::vector<int> v {1, 2, 3, 4, 5, 6, 7 /*...*/};
int sum {std::accumulate(v.begin(), v.end(), 0)};
编译器将会生成一个对accumulate
调用的循,其可能与下面代码类似:
int sum {0};
for (size_t i {0}; i < v.size(); ++i) {
sum += v[i];
}
从这点说起,当编译器开启向量化时,就会生成类似如下的代码。每次循环会进行 4 次累加,这样循环次数就要比之前减少 4 倍。为了简单说明问题,我们这里没有考虑不为 4 倍数个元素的情况:
int sum {0};
for (size_t i {0}; i < v.size() / 4; i += 4) {
sum += v[i] + v[i+1] + v[i + 2] + v[i + 3];
}
// if v.size() / 4 has a remainder,
// real code has to deal with that also.
为什么要这样做呢?因为 CPU 指令能支持一个指令执行这种sum += v[i] + v[i+1] + v[i+2] + v[i+3];
,而不是调用4个add
指令。使用尽可能少的指令完成尽可能多的操,这样就能加速程序的运行。
自动向量化非常困,因为编译器需非常了解我们的程序,这样才能进行加速的情况,不让程序的结果出错。至少可以通过使用标准算法来帮助编译器。因为这样能让编译器更加了解哪些数据流能够并,而不是从复杂的循环中对数据流的依赖进行分析。
1.7 c++的 cast 有几种?分别在什么时候用?
C++提供了四种类型转换操作符,分别用于不同的场景:
-
static_cast
:最常用的类型转换。用于非多态类型的转换。可以用来进行任何隐式转换,比如非 const 转 const,void 指针转具体类型指针,以及任何用户定义的类型转换等。int i = 10; float f = static_cast<float>(i); // 整型转浮点型
-
dynamic_cast
:主要用于处理多态性,安全地将基类指针或引用转换为派生类的指针或引用,而且在转换不成功时能够检测到。只能用于包含虚函数的类之间的转换。Base* b = new Derived; Derived* d = dynamic_cast<Derived*>(b); // 基类指针转派生类指针 if (d) { /* 成功转换 */ }
-
const_cast
:用于修改类型的 const 或 volatile 属性。最常见的用途是去除对象的 const 性质,以允许对 const 对象的修改。const int ci = 10; int* modifiable = const_cast<int*>(&ci); *modifiable = 5; // 实际上修改const对象是未定义行为
-
reinterpret_cast
:提供了低级别的重新解释类型的能力,可以将任何指针转换成任何其他类型的指针(甚至不相关的类型),也可以将指针转换成足够大的整数类型,反之亦然。使用时需要特别小心,因为它可能会导致平台依赖的代码。long p = 0x12345678; char* cp = reinterpret_cast<char*>(&p); // 将long型指针转换为char型指针
但是dynamic_cast
因为是运行时提供一种检查能力去做指针类型转换的,部分性能敏感的代码,可能不允许使用这个。每种类型转换操作符都有其特定的用途,选择合适的转换操作符可以使代码更安全、更清晰。
2. inside cpp
2.1 引用是怎么实现的?
在 C++ 中,当我们说”引用”时,我们通常不会说它被”拷贝”,因为引用本身并不占用任何存储空间,它只是一个别名
。当你将一个对象作为引用传递给函数时,实际上并没有发生任何拷贝操作。函数接收的是对原始对象的直接引用,而不是任何形式的拷贝。
然而,从底层实现的角度来看,引用在某种程度上可以被视为一个常量指针。当你创建一个引用并将其初始化为一个对象时,编译器会在底层创建一个指向该对象的常量指针。这个指针在初始化后就不能改变,它将一直指向初始化时的那个对象。因此,当你通过引用访问对象时,实际上是通过这个常量指针访问的。
但是,这并不意味着引用是通过拷贝指针来实现的。引用的实现细节可能因编译器和平台的不同而不同。
2.2 如果说 a 和 b 都在栈上,那怎么取 b 的值呢?需要每一次取值都经历出栈和入栈吗?
int main() {
int a = 1;
int b = 2;
// ...
return 0;
}
在函数执行过程中,局部变量 a 和 b 都存储在栈上。栈是一个连续的内存区域,局部变量在栈帧中按顺序分配。取变量 b 的值并不需要每次都经历出栈和入栈操作,而是通过栈帧中的偏移量直接访问。
main:
push ebp ; 保存调用者的基址指针
mov ebp, esp ; 设置当前栈帧的基址指针
sub esp, 8 ; 为局部变量 a 和 b 分配 8 字节的空间
mov DWORD PTR [ebp-4], 1 ; 将 1 存储到 a
mov DWORD PTR [ebp-8], 2 ; 将 2 存储到 b
; 访问 b 的值
mov eax, DWORD PTR [ebp-8] ; 将 b 的值加载到 eax 寄存器
mov esp, ebp ; 恢复栈指针
pop ebp ; 恢复基址指针
ret ; 返回调用者
10 c/c++是转成汇编语言的。 那汇编语言是如何处理自定义类型的?
C/C++代码是被转成汇编语言的。那么,汇编语言是如何处理自定义类型的呢?
汇编语言本身并没有自定义类型的概念,它只知道字节和地址。当在 C++ 中定义一个类型,例如一个结构体或类时,编译器会根据定义来决定如何在内存中布局数据,以及如何生成对应的汇编代码来访问这些数据。
例如,若定义一个包含两个整数的结构体:
struct MyStruct {
int a;
int b;
};
在大多数现代系统中,一个 int
需要 4 个字节,所以编译器会知道这个结构体需要 8 个字节的空间。当创建一个 MyStruct
对象并访问它的成员时,编译器会生成对应的汇编代码来读取或写入这些地址。
假设在 x86 - 64 架构下,使用 GCC 编译器,以下是一个简单示例,展示上述结构体在汇编层面的操作(简化示意):
#include <iostream>
struct MyStruct {
int a;
int b;
};
int main() {
MyStruct s;
s.a = 5;
s.b = 10;
std::cout << s.a << " " << s.b << std::endl;
return 0;
}
使用 g++ -S
命令生成汇编代码(部分关键代码如下):
.file "test.cpp"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
subq $48, %rsp
.cfi_def_cfa_offset 56
movl $5, -8(%rbp) ; 将 5 赋值给 s.a,-8(%rbp) 是 s.a 在栈上的地址
movl $10, -4(%rbp) ; 将 10 赋值给 s.b,-4(%rbp) 是 s.b 在栈上的地址
movl -8(%rbp), %eax ; 将 s.a 的值加载到 %eax 寄存器
movl %eax, %esi
movl $.LC0, %edi
movl $0, %eax
call printf
movl -4(%rbp), %eax ; 将 s.b 的值加载到 %eax 寄存器
movl %eax, %esi
movl $.LC0, %edi
movl $0, %eax
call printf
movl $0, %eax
addq $48, %rsp
.cfi_def_cfa_offset 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.section .rodata
.LC0:
.string "%d "
.ident "GCC: (Ubuntu 9.4.0 - 1ubuntu1~20.04.1) 9.4.0"
.section .note.GNU-stack,"",@progbits
可以看到,汇编代码通过具体的内存地址操作来处理结构体成员。但是,这个过程是由编译器完成的,汇编语言本身并不知道 MyStruct
这个结构体,它只知道如何处理字节和地址。这就是为什么在汇编语言中,需要手动管理所有的内存布局和数据访问。
100. quiz
1. c++中 NULL 和 nullptr 的区别
在 C 语言里,NULL 一般借助宏定义为#define NULL ((void *)0)
。从本质上来说,NULL 代表的是一个空指针。下面这段代码在 C 语言环境中是能够正常编译的:
#define NULL ((void *)0)
int *pi = NULL;
char *pc = NULL;
这是由于在 C 语言中,当把void*
类型的空指针赋值给int*
或者char*
类型的指针时,会自动进行隐式类型转换,从而将void*
转换为对应的指针类型。不过,如果使用 C++编译器来编译上述代码,就会出现错误。这是因为 C++属于强类型语言,它不允许将void*
类型的指针隐式转换为其他类型的指针。所以,在 C++环境中,编译器提供的头文件对 NULL 的定义进行了调整:
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
然而,把 NULL 定义为 0 会引发函数重载时的类型匹配问题。例如下面的代码:
void foo(int);
void foo(char*);
foo(NULL); // 这里会调用哪个函数呢?
在 C++里,当执行foo(NULL)
这一调用时,实际上传递的实参是整数 0。因为 NULL 被定义成了 0,所以它会优先匹配参数类型为int
的重载函数,而不是参数类型为char*
的函数。这种情况往往和程序员的预期不符,容易引发隐藏的错误。为了避免这类问题,在 C++11 及后续的版本中,建议使用nullptr
来表示空指针。nullptr
是一种特殊的空指针类型,能够隐式转换为任意类型的指针,而且不会和整数类型产生混淆。使用nullptr
可以让代码的意图更加清晰明确:
2. 一个通过new
创建出来的指针,若被delete
两次会怎样?
如果一个指针被delete
两次,会引发未定义行为(Undefined Behavior)。这是因为在第一次执行delete
之后,该指针所指向的已不再是有效的内存区域。再次尝试对其执行delete
操作,实际上是对无效内存进行操作,这在程序运行规则中是不允许的。
未定义行为可能引发多种不良后果,其中包括但不限于以下情况:
- 程序崩溃:操作系统或运行时环境检测到非法内存访问,从而强制终止程序运行。
- 数据损坏:对无效内存的操作可能会改写其他合法数据,导致程序后续逻辑出现错误。
- 出现难以预测和解释的行为:由于未定义行为不受特定规则约束,程序的运行结果可能在不同的编译环境、运行环境甚至不同的运行时刻都有所不同,给调试和维护带来极大困难。
为了避免此类情况发生,在编写代码时,务必保证每个new
操作都有且仅有一个对应的delete
操作,并且每个delete
操作仅执行一次。在 C++ 代码中,std::unique_ptr
类通过封装new
和delete
操作,实现了对资源的自动管理,有效避免了这种因重复释放指针而引发的问题。
3. 为什么在 delete 之后,通常都会将指针设置为 nullptr
在 C++编程中,当使用delete
释放一个指针所指向的内存后,通常会将该指针设置为nullptr
,这主要基于以下几方面原因:
-
防止产生悬挂指针:当使用
delete
释放指针所指向的内存后,该指针就成为了悬挂指针(Dangling Pointer)。此时它虽不再指向有效的内存区域,但仍保留着之前的地址值。若后续不小心再次使用这个悬挂指针,就会引发未定义行为,可能导致程序崩溃或出现难以排查的错误。而将指针设置为nullptr
,能有效避免这种情况,因为nullptr
是一个特殊指针值,表示该指针不指向任何对象,使用指向nullptr
的指针进行操作(如解引用)会在大多数情况下引发明确的运行时错误,便于开发者定位问题。 -
安全地重复执行 delete 操作:在 C++语言规则中,对
nullptr
执行delete
操作是安全的,不会产生任何实际效果。因此,若将已释放内存的指针设置为nullptr
,后续即便不小心再次尝试对其执行delete
操作,也不会导致未定义行为,从而增强了程序的健壮性。 -
方便检查指针是否已被释放:将指针设置为
nullptr
后,通过简单检查该指针是否等于nullptr
,就能清晰判断它所指向的内存是否已经被释放。这在复杂的代码逻辑中,对于追踪指针状态、确保内存管理的正确性非常有帮助。
综上所述,在使用delete
释放指针后,将其设置为nullptr
是一种良好的编程习惯,有助于提高程序的安全性与稳定性,减少因内存管理不当而引发的错误。
5. 静态绑定和虚函数
#include <iostream>
struct A {
virtual void foo (int a = 1) {
std::cout << "A" << a;
}
};
struct B : A {
virtual void foo (int a = 2) {
std::cout << "B" << a;
}
};
int main () {
A *b = new B;
b->foo(); // B1
}
4. 通过指针访问和直接访问的区别是什么?
struct Point3d {
double x;
};
Point3d origin;
Point3d* pt = &origin;
origin.x = 0.0; // (1)
pt->x = 0.0; // (2)
(1) 和 (2) 这两种方式均用于将 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
。但是(1)
会比(2)
要好,(2)
会有运行时开销。
6. 一般哪些函数可以使用 noexcept?
在 C++ 中,noexcept
关键字用于表明一个函数不会抛出任何异常。这对于提升代码的性能与可靠性有着重要意义。以下是几类常见可使用noexcept
的函数:
- 析构函数:析构函数一般不应抛出任何异常。这是因为若在析构函数中抛出异常,极有可能致使程序出现未定义行为。例如,当对象被自动销毁或容器中的对象析构时,若析构函数抛出异常,程序的后续行为将无法预测,可能导致崩溃或数据损坏等严重问题。
- 移动构造函数与移动赋值操作符:这些函数通常应标记为
noexcept
。因为它们大多仅涉及指针和基本类型的转移,正常情况下不应抛出异常。而且,标记为noexcept
的移动操作能被标准库容器(如std::vector
)更高效地运用。比如,当std::vector
进行扩容或重新分配内存时,若移动操作标记为noexcept
,std::vector
可以采用更优化的策略,避免不必要的复制操作,从而显著提升性能。 - 交换函数:像
std::swap
这类交换函数,通常也应标记为noexcept
。这是由于它们通常仅涉及指针和基本类型的转移,正常情况下不会抛出异常。例如,在进行两个对象的交换操作时,仅仅是交换它们内部的指针或基本数据成员,这种操作的稳定性较高,不易引发异常。 - 其他不会抛出异常的函数:倘若你明确知晓某个函数不会抛出异常,那么就应当使用
noexcept
关键字。这不仅有助于编译器对代码进行优化,例如,编译器可能会针对noexcept
函数进行特定的优化,生成更高效的机器码;同时也能向其他开发者清晰地表明该函数不会引发异常,使得代码的行为更加明确和可预测。
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; // 隐式转换
需要注意的是,尽管隐式转换看似方便,但它可能引发一些意想不到的问题。比如,当函数重载时,隐式转换可能会导致编译器选择错误的函数版本。因此,一般建议尽量避免使用隐式转换,或者至少要确保清楚地知道它何时会发生以及会产生什么效果。
8. 多态场景下,调用哪个方法?
在C++中,考虑以下场景:有两个同名函数func()
,一个函数的形参是Foo
类型指针,另一个函数的形参是Bar
类型指针,并且Bar
类型是Foo
类型的派生类。如果有Foo* bar = new Bar();
,然后调用func(bar)
,那么会调用哪个函数呢?
答案是会调用形参为Foo
类型指针的func
函数。因为在C++中,这种情况属于函数重载。函数重载是在编译时根据实参的静态类型来确定调用哪个函数版本。在这个例子中,实参bar
的静态类型是Foo*
,所以编译器会选择调用形参为Foo
类型指针的func
函数。
需要注意的是,如果想要实现根据对象的实际类型(动态类型)来决定调用哪个函数,即实现多态中的动态绑定,则需要在基类Foo
中将相关函数声明为虚函数,并且在派生类Bar
中重写该虚函数,这样通过基类指针或引用调用虚函数时,才会在运行时根据对象的实际类型来确定调用哪个函数版本。但本题中未提及虚函数相关内容,所以按照函数重载的规则进行匹配。
9. 不使用 final 怎么实现 final 效果
在 C++ 中,如果希望一个类 B
不可以被继承,可以采用以下方式。首先,定义一个类 A
,将 A
的构造函数私有化。然后让类 B
继承自 A
,并声明 B
是 A
的友元。这样,B
能够正常构造,因为作为友元,B
可以访问 A
的私有构造函数。然而,当有类 C
试图继承 B
时,由于 C
不是 A
的友元,不能调用 A
的私有构造函数,所以 C
无法继承 B
,从而实现了类似 final
的阻止类被继承的效果。以下是具体代码示例:
class A {
private:
A() {} // A 的构造函数私有化
friend class B; // 声明 B 为 A 的友元
};
class B : public A {
public:
B() {} // B 可以正常构造,因为是 A 的友元
};
// 尝试继承 B
// class C : public B {
// public:
// C() {} // 这里会报错,因为 C 不能调用 A 的私有构造函数
// };
在上述代码中,若取消对 class C
定义部分的注释,编译时会报错,提示无法访问 A
的私有构造函数,从而表明 C
不能继承 B
,达成了类似 final
关键字阻止类被继承的效果。
10. 函数声明陷阱
#include <iostream>
struct X {
X() { std::cout << "X"; }
};
int main() {
{
// confusing case: 可能会引起误解
X x(); // 这是声明了一个名为x,返回类型为X的函数,还是声明了一个名为x,使用默认构造的对象?
}
{
// 方法1:省略括号(推荐)
X x; // 正确创建对象x,调用默认构造函数
}
{
// 方法2:使用统一初始化语法(C++11及以后)
X x{}; // 同样正确,避免了语法歧义
}
return 0;
}
11. 黑魔法
for (int i = 0; i < n; i++) {
cout << ans[i] << " \n"[i == n - 1];
}
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";
}
Enjoy Reading This Article?
Here are some more articles you might like to read next: