迈向 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++提供了四种类型转换操作符,分别用于不同的场景:

  1. static_cast:最常用的类型转换。用于非多态类型的转换。可以用来进行任何隐式转换,比如非 const 转 const,void 指针转具体类型指针,以及任何用户定义的类型转换等。

    int i = 10;
    float f = static_cast<float>(i); // 整型转浮点型
    
  2. dynamic_cast:主要用于处理多态性,安全地将基类指针或引用转换为派生类的指针或引用,而且在转换不成功时能够检测到。只能用于包含虚函数的类之间的转换。

    Base* b = new Derived;
    Derived* d = dynamic_cast<Derived*>(b); // 基类指针转派生类指针
    if (d) { /* 成功转换 */ }
    
  3. const_cast:用于修改类型的 const 或 volatile 属性。最常见的用途是去除对象的 const 性质,以允许对 const 对象的修改。

    const int ci = 10;
    int* modifiable = const_cast<int*>(&ci);
    *modifiable = 5; // 实际上修改const对象是未定义行为
    
  4. 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类通过封装newdelete操作,实现了对资源的自动管理,有效避免了这种因重复释放指针而引发的问题。

3. 为什么在 delete 之后,通常都会将指针设置为 nullptr

在 C++编程中,当使用delete释放一个指针所指向的内存后,通常会将该指针设置为nullptr,这主要基于以下几方面原因:

  1. 防止产生悬挂指针:当使用delete释放指针所指向的内存后,该指针就成为了悬挂指针(Dangling Pointer)。此时它虽不再指向有效的内存区域,但仍保留着之前的地址值。若后续不小心再次使用这个悬挂指针,就会引发未定义行为,可能导致程序崩溃或出现难以排查的错误。而将指针设置为nullptr,能有效避免这种情况,因为nullptr是一个特殊指针值,表示该指针不指向任何对象,使用指向nullptr的指针进行操作(如解引用)会在大多数情况下引发明确的运行时错误,便于开发者定位问题。

  2. 安全地重复执行 delete 操作:在 C++语言规则中,对nullptr执行delete操作是安全的,不会产生任何实际效果。因此,若将已释放内存的指针设置为nullptr,后续即便不小心再次尝试对其执行delete操作,也不会导致未定义行为,从而增强了程序的健壮性。

  3. 方便检查指针是否已被释放:将指针设置为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 对象 originx 成员值设置为 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

总体而言,这两种实现效果一致,都能将 originx 成员值设为 0.0 。但是(1)会比(2)要好,(2)会有运行时开销。

6. 一般哪些函数可以使用 noexcept?

在 C++ 中,noexcept关键字用于表明一个函数不会抛出任何异常。这对于提升代码的性能与可靠性有着重要意义。以下是几类常见可使用noexcept的函数:

  1. 析构函数:析构函数一般不应抛出任何异常。这是因为若在析构函数中抛出异常,极有可能致使程序出现未定义行为。例如,当对象被自动销毁或容器中的对象析构时,若析构函数抛出异常,程序的后续行为将无法预测,可能导致崩溃或数据损坏等严重问题。
  2. 移动构造函数与移动赋值操作符:这些函数通常应标记为noexcept。因为它们大多仅涉及指针和基本类型的转移,正常情况下不应抛出异常。而且,标记为noexcept的移动操作能被标准库容器(如std::vector)更高效地运用。比如,当std::vector进行扩容或重新分配内存时,若移动操作标记为noexceptstd::vector可以采用更优化的策略,避免不必要的复制操作,从而显著提升性能。
  3. 交换函数:像std::swap这类交换函数,通常也应标记为noexcept。这是由于它们通常仅涉及指针和基本类型的转移,正常情况下不会抛出异常。例如,在进行两个对象的交换操作时,仅仅是交换它们内部的指针或基本数据成员,这种操作的稳定性较高,不易引发异常。
  4. 其他不会抛出异常的函数:倘若你明确知晓某个函数不会抛出异常,那么就应当使用noexcept关键字。这不仅有助于编译器对代码进行优化,例如,编译器可能会针对noexcept函数进行特定的优化,生成更高效的机器码;同时也能向其他开发者清晰地表明该函数不会引发异常,使得代码的行为更加明确和可预测。

7. c++怎么定义隐式转换规则

在 C++ 中,隐式转换(也称为自动类型转换)由编译器自动执行。这些转换规则由 C++ 语言明确规定,例如从 intdouble 的转换,或者从派生类到基类的转换。 然而,你也可以为自定义类型定义隐式转换规则。这可以通过定义转换函数来达成。转换函数是一种特殊的成员函数,它能够将一个类的对象转换为其他类型的对象。 例如,假设你有一个名为 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,并声明 BA 的友元。这样,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:

  • (六)模板那些事儿:类型擦除
  • (五)多线程那些事儿:并行库 openmp
  • (五)模板那些事儿:模板元
  • (四)多线程那些事儿:并行库 tbb
  • (三)多线程那些事儿:怎么用好