首页 » 现代C++教程 读书笔记

现代C++教程 读书笔记

习题答案:https://github.com/changkun/modern-cpp-tutorial/tree/master/exercises

序言

本文传统C++ 是指C++ 98及之前的标准。

C++ 14/17是对C++ 11的重要补充和优化;而C++ 20则将这门语言领进了现代化的大门。

关于一些特性的初探:

  • auto关键字语义给操纵极为复杂的模板类型提供了底层支持;
  • lambda表达式基于C++匿名函数的闭包特性;
  • 右值引用的出现解决了C++长期被人诟病的临时对象效率问题;

第一章迈向现代C++

被弃用的特性

弃用并非不能用,只是暗示这些特性将从未来的标准消失。

  • 不允许使用字符串面值常量赋值给char *,如果使用应该是用const char**或者auto。
  • C++98异常说明、unexpected_handler, set_unexpected()等特性被弃用,应该使用noexcept。
  • auto_ptr被弃用,用使用unique_ptr。
  • register关键词被弃用。
  • bool类型的++操作被弃用
  • 如果一个类有析构函数,为其生成拷贝构造函数和拷贝复制运算符的特性被弃用了。
  • C风格的类型转换被弃用了(即在变量前是用(convert_type)),应该使用static_cast、reinterpret_cast、const_cast来进行类型转换。
  • ……等等

与C的兼容

C++不是C的一个超集。

用extern "C"特性时,将C语言代码和C++语言进行分离编译,再统一链接。

第二章语言可用性的强化

变量/常量/流程

nullptr

nullptr的是替代NULL,C++不允许将void *隐式转换到其他类型。

void foo(char *);
void foo(int);

//直接调用将会调用void foo(int);;这违反常理
foo(NULL);

所有的空指针一律使用nullptr,而不要用NULL。

constexpr

修饰变量,const并未区分编译器常量和运行期常量,constexpr限定编译器常量。

修饰函数,constexpr修饰的函数返回值不一定是编译期常量,这个有点像inline。

只读的语义用const,常量的语义用constexpr。

if/switch变量声明强化

C++中可以将变量声名放在if和switch中,作用于就从函数下降到本身的作用域中。

    if (int a = f(); a != 1) {
        // 代码块A
        cout << a << endl;
    } else if (int b = g(); b != 2) {
        // 代码块B
        a += b;
        cout << a << endl;
    } else {
        // 代码块C
        a -= b;
        cout << a << endl;
    }

初始化列表

用initializer_list的构造函数被称为初始化列表构造函数,具有这种构造函数的类型将在初始化时被特殊处理。

结构化绑定

std::tuple<int, double, std::string> f(){
    return std::make_tuple(1, 2.3, "456");
}

int main(void){
    auto [x, y, z] = f();
    std::cout << x << ", " << y << ", " << z << std::endl;
    return 0;
}

auto,decltype

用于类型推导。

auto:

  • 当类型不为引用时,auto 的推导结果将不保留表达式的 const 属性;
  • 当类型为引用时,auto 的推导结果将保留表达式的 const 属性;
  • auto 关键字不能定义数组。

还有一个尾返回类型(C++11),利用auto关键字将返回类型后置,但是在C++14中,可以让普通函数具备返回值推导:

// C++ 11
template<typename T, typename U>
auto add(T x, U y) -> decltype(x+y){
    return x + y;
}

// C++ 14
template<typename T, typename U>
auto add2(T x, U y){
    return x + y;
}

decltype(auto)

C++ 14开始提供的用法,和直接使用auto相比最大的区别在于这个能够自动识别是否是引用&,auto只能识别到数据类型,demo:

std::string lookup1();
std::string& lookup2();

// C++ 11的封装形式如下:
std::string look_up_string_1(){ return lookup1(); }
std::string& look_up_string_2(){ return lookup2(); }

// C++ 14可以如下:
decltype(auto) look_up_string_1(){ return lookup1(); }
decltype(auto) look_up_string_2(){ return lookup2(); }

if constexpr

C++ 17将constexpr关键字引入if判断,在编译期完成判断。

template<typename T>
auto print_type_info(const T& t){
    if constexpr(std::istegral<T>::value){
        return t + 1;
    } else {
        return t + 0.001;
    }
}
int main() {
    std::cout << print_type_info(5) << std::endl;
    std::cout << print_type_info(3.14) << std::endl;
}

会被编译成这样:

int print_type_info(const int& t){
    return t + 1;
}
double print_type_info(const double& t){
    return t + 0.001;
}
//...

其实这个语法糖…我不太能明白用处,写成两个函数不行吗,可能因为我对模板编程接触实在少。

区间for迭代

像python的for循环,要注意的主要是使用auto想修改的话,需要使用auto &。

std::vector<obj_class> vec;
//...
for(auto &v: vec){
  // do sth
}

对象/模板

外部模板

传统C++中,模板只有使用时才会被实例化。并且每个编译单元(文件)都会被实例化,这增加了时间,C++11引入了外部模板:

template class std::vector<bool>;                   // 强制实例化
extern template class std::vector<double>;          // 不在当前编译文件中实例化模板

类型别名模板

模板和类型是不同的,模板是用来生产类型的。

typedef可以为类型定义一个新的名称,但没法为模板定义一个新名称,因为木板不是类型,C++ 11引入了using解决这个问题。

所以typedef给类型定义别名,using除了typedef能做的,还能够给模板定义别名。

总结一下using的三个用途

  • 引入命名空间
  • 指定别名(对比typedef 有两个好处,第一更加清晰,第二可以对模板)
  • 在之类中引用基类成员

默认模板参数

template<typename T = int, typename U = int>
auto add(T x, U y){
    return x + y;
}

变长参数模板

这个好像篡改了语法,所有的关键词后面加上…就变成了这个相关的功能,感觉还挺复杂的,列一下demo:

template<typename... Ts> class Magic;      // 其中Ts就是变长模板的“类型名”,可以接受0~n个
template<typename... Args> void printf(const std::string &str, Args... args);  // 有类型安全的printf
template<typename... Ts>
void magic(Ts... args){
    std::cout << sizeof...(args) << std::endl;
}
// 参数解包,1. 递归模板函数
template<typename T0>
void printf1(T0 value){
    std::cout << value << std::endl;
}
template<typename T, typename... Ts>
void printf1(T value, Ts... args){
    std::cout << value << std::endl;
    printf1(args...);
}
// 参数解包,2. 变参模板展开
template<typename T0, typename... T>
void printf2(T0 t0, T... t){
    std::cout << t0 << std::endl;
    if constexpr(sizeof...(t) > 0) printf2(t...);
}
// 参数解包,3. 初始化列表展开
template<typename T, typename... Ts>
auto printf3(T value, Ts... args){
    std::cout << value << std::endl;
    (void) std::initializer_list<T>{([&args]{
        std::cout << args << std::endl;
    }(), value)...};
}

折叠表达式

template<typename... T>
auto sum(T... t){
    return (t + ...);
}

非类型模板参数推导

template <auto value>
void foo() {
    std::cout << value << std::endl;
}

int main() {
    foo<10>();     // value被推导为int类型
}

委托构造

构造函数可以在同一个类的一个构造函数中调用另一个构造函数。

class Base{
public:
  int value1;
  int value2;
  Base() {
    value1 = 1;
  }
  Base(int value): Base() {
    value2 = value;
  }
};

使用委托构造函数,就不能再使用初始化列表构造其他成员了。

继承构造

class Base{
public:
    Base();
    Base(int);
}:

class SubClass: public Base{
    using Base::Base;    // 继承构造
};

假设一旦使用了继承构造函数,编译器就不会为派生类生成默认构造函数。这样,我们得注意继承构造函数无參版本号是不是有须要。

显示虚函数重载

override显示告诉编译器这个函数是重载虚函数。

final是显示告诉编译器,防止被继承这个函数。

显示禁用默认函数

传统C++中,如果程序员没有提供,编译器会为对象生成默认构造、复制构造、赋值算符以及析构函数,同时也为所有类定义了注入new delete这些运算符。

C++ 11的解决方案:

class Magic {
public:
    Magic() = default;                        // 显式使用编译器生成的构造
    Magic& operator=(const Magic&) = delete;  // 显示声明拒绝编译器生成构造
    Magic(int magic_number);
};

强类型枚举

C++ 11引入了枚举类,使用enum class的语法进行声名:

enum class new_enum: unsigned int {
    value1,
    value2,
    value3 = 100,
    value4 = 100
};

这样的枚举不能被隐式转化为整数,不能与整数进行比较,不能与不同枚举类型的值进行比较。

也解决了传统C++中,同一个命名空间的不同枚举类型的枚举值名字不能相同的问题。

第三章 语言运行期的强化

lambda表达式

值拷贝:被捕获的变量在lambda表达式创建时拷贝,而非调用时才拷贝;

  1. 引用拷贝:和正常引用无异;
  2. 隐私捕获:直接填[=]或者[&]让编译器自行推导;
  3. 表达式捕获:有些变量不允许复制和引用,C++提供了表达式捕获可以进行任意的初始化,让我们可以把这些变量变成右值。

其中当lambda表达式的捕获列表为空时,闭包对象还能够转换为函数指针值进行传递。

using foo = void(int);
void functional(foo f){ f(1); }
int main() {
    auto f = [](int value) { std::cout << value << std::endl; };
    functional(f);
}

泛型lambda

C++ 14支持lambda使用auto作为类型,提供泛型lambda:

auto add = [](auto x, auto y){
    return x + y;
}
add(1, 2);
add(1.1, 2.2);

std::function

函数的容器,对可调用实体的一种类型安全的包裹(函数指针的调用不是类型安全的)。

std::bind和std::placeholder

  • 其中std::placeholder::_1表示bind对象调用时的第一个参数;
  • std::bind支持嵌套绑定,如下demo
int foo(int a, int b, int c) { return a + b + c; }
int g(int n) { return n + 2; }

int main(){
    auto bindFoo = std::bind(foo, std::placeholders::_1, 1, std::bind(g, 3));
    bindFoo(3);
}

右值引用

关于左值、右值、将亡值的概念我大概清楚了,但是书中这个例子我没理解清楚:

#include <iostream>

using namespace std;

class ClassA{
public:
    int *pointer;
    ClassA(): pointer(new int(1)){
        cout << " 构造" << pointer << endl;
    }
    ClassA(ClassA& a): pointer(new int(*a.pointer)){
        cout << " 拷贝构造" << pointer << endl;
    }
    ClassA(ClassA&& a): pointer(a.pointer) {
        a.pointer = nullptr;
        cout << " 移动" << pointer << endl;
    }
    ~ClassA(){
        cout << " 析构" << pointer << endl;
        delete pointer;
    }
};

ClassA return_rvalue(bool test){
    ClassA a, b;
    if(test) return a;
    else return b;
}

int main(void){
    ClassA obj = return_rvalue(false);
    cout << "obj: " << endl;
    cout << obj.pointer << endl;
    cout << *obj.pointer << endl;
}

/* 打印结果如下:
 构造0x555b80d39e70
 构造0x555b80d3a2a0
 移动0x555b80d3a2a0
 析构0
 析构0x555b80d39e70
obj:
0x555b80d3a2a0
1
 析构0x555b80d3a2a0
*/

这里我觉得移动构造应该会执行两次才对,return_rvalue从b对象到将亡值一次复制,从将亡值到main函数中的obj一次复制,实在不解为什么只执行了一次,就深入看了一下汇编,便有了本笔记的最后一章

我不放汇编代码,直接给出看汇编的结论:

main函数在调用return_rvalue函数时,除了传递参数false,还传递了一个8字节的内存地址进去,return_rvalue在返回时并不是创建了一个新的对象返回给上一层,而是直接把b移动给了上一层传进来的对象里。

换句话说,obj内存开辟是在main函数里做的,但拷贝构造函数发生在return_rvalue返回之前

在这里我还做了几个测试,在C中,当返回的对象内存大于4字节时,返回值是通过调用者提供的内存返回的。C++是的规则好像特殊一点,跟引用和右值有关系。

右值引用

T&& a是一个右值引用,它引用的对象是一个右值,但是它本身是一个左值,可以继续引用其他对象。

移动语义:std::move

会将参数的内容“移动”到左边,参数的对象会被清空。

完美转发

引用坍缩规则:在传统C++中,不能对一个引用类型继续进行引用,但右值引用的出现放宽了这一做法,从而引起坍缩规则。无论模板参数是什么类型的引用,当且仅当实参类型为右引用的时候,模板参数才能被推到位右引用类型

第四章 容器

std:array

  • 相比vector,array大小是固定的,空间消耗可控;
  • 相比传统数组,array封装的更加安全。

和C风格的接口兼容时:

void foo(int *p, int len){ return ; }
std::array<int, 4> arr = {1, 2, 3, 4};

// foo(arr, arr.size());  // 非法,无法隐式转换
foo(&arr[0], arr.size());
foo(arr.data(), arr.size());

std:forward_list

std::list是双向链表,这个是单向链表,不展开

std::unordered_map, std::unorder_set, std::unordered_multimap, …

std::map和std::set都是用红黑树实现的;

这里unordered_xx都是用hash表实现的;

元组

元组的三个核心函数:

  1. std::make_tuple: 构造元组
  2. std::get: 获得元祖某个位置的值
  3. std::tie: 元祖拆包
auto student = std::make_tuple(1.7, 'D', "张三");
auto lv = std::get<2>(student);
auto score = std::get<1>(student);
double gpa;
char grade;
std::string name;
std::tie(gpa, grade, name) = student;

运行期索引

上面的例子中,std::get 其中index必须是常量,不能是变量。

C++ 17 引入了variant<>,但用起来还挺复杂的,用到再深究吧。

元组的合并与遍历

std::tuple_cat(student1, student2);可以合并两个元组

遍历需要用到上面variant和元组模板的::value属性。

书里没写,我在这多插嘴一句,元组的实现在底层其实是多个类的继承,所以每个元组类型都是通过变长参数模板+继承来做的,因为继承内存其实非常紧凑,而每个元组本质上是一个对象,每个元组的类的长度是固定的,所以有一个value的静态变量来获取长度。

从这里向下的内容严格来说,只算是库的内容,不算是语言层面的内容了

从这里向下的内容严格来说,只算是库的内容,不算是语言层面的内容了

第五章 智能指针与内存管理

RAII和引用计数

RAII是指在析构函数中释放资源,就不会忘记释放资源了。

引用计数是指,记录对象被引用的次数。

这俩是所有智能指针实现的低层级制。

std::shared_ptr, std::make_shared

引用计数变为0时,对象会被delete。

std::unique_ptr

禁止与其他的智能指针共享一个对象,从而保证代码的安全。

不可复制,但可以移动。

std::weak_ptr

weak_ptr不会引起计数增加

第六章 正则表达式

std::regex

之前一般的解决方案是使用boost的正则表达式库。

C++ 11 将正则表达式引入了标准库的支持。

第七章 并行与并发

线程基础

std::thread 用于创建一个执行的线程实例。

get_id() 可以获得所创建线程的线程ID,使用join() 来等待一个线程结束。

#include <iostream>
#include <thread>

int main() {
    std::thread t([](){
        std::cout << "hello world." << std::endl;
    });
    t.join();
    return 0;
}

互斥量与临界区

std::mutex是最基本的mutex类,实例化可以创建互斥量,然后通过lock()上锁,unlock()解锁。还有一个RAI语法的模板类std::lock_guard和std::unique_lock。

std::unique_lock更自由,可以主动lock和unlock。

std::future

跟python里的future挺像的,通过回调的形式来给予异步线程的返回值。

条件变量

std::condition_variable是为了解决死锁而生,实例被创建主要就是用于唤醒等待线程。std::conditioin_variable的notify_one()用于唤醒一个线程;notify_all()用于唤醒所有线程。

原子操作与内存模型

a=1, b=3; 因为CPU可能乱序,可能导致另外一个线程看到b先为3;

mutex可以解决这个问题,因为mutex是操作系统级别的功能;

同时C++11还引入了std::automic模板,为浮点整数提供了基本的数据成员函数。

一致性模型

当多个线程对一个变量v操作时,每个线程都能感受到v的变化,但对于v而言,表现为顺序执行的程序,v并没有因为引入多线程而得到任何效率上的收益。如何适当加速呢?削弱原子操作在进程间的同步调节。

  • 线性一致性;
  • 顺序一致性;
  • 因果一致性;
  • 最终一致性;

内存顺序

为了追求极致的性能,实现各种强度要求的一致性,C++为原子操作定义了六种不同的内存顺序std::memory_order的选项,表达了四种多线程间的同步模型:

  1. 宽松模型:std::memory_order_relaxed
  2. 释放/消费模型:std::memory_order_consume, std::memory_order_release
  3. 释放/获取模型:std::memory_order_acquire, std::memory_order_acq_rel, std::memory_order_release
  4. 顺序一致模型:std::memroy_order_seq_cst

第八章 其他杂项

long long in

C99就已经引入了,C++ 11中也引入了,至少是一个64位的比特数。

noexcept的修饰和操作

C++11将异常声明简化为以下两种情况,并用noexcept进行限制:

  1. 函数可能抛出异常;
  2. 函数不能抛出异常;

noexcept还能做操作符,用于操作一个表达式,无异常时返回true,否则返回false。

noexcept(may_throw())
noexcept(no_throw())

noexcept修饰完函数之后,外部不会捕获到异常。

自定义字面量

没想到使用场景,demo:

std::string operator"" _wow1(const char *wow1, size_t len){
    return std::string(wow1) + "woooooow, amazing!";
}

std::string operator"" _wow2(unsigned long long i){
    return std::to_string(i) + "woooooooooooow, amazing!!";
}

int main(){
    auto str = "abc"_wow1;
    auto num = 1_wow2;
    std::cout << str << std::endl << num << std::endl;
}

内存对齐

C++11引入了两个新的关键字alignof和alignas来支持内存对齐进行控制。

alignof类似于sizeof,获取跟平台相关的std::size_t类型的值,用于查询该平台的对齐方式。

alignas用于修饰某个结构的对齐方式。

第九章 展望:C++20简介

诸如Concept/Module/Coroutine/Ranges等特性的提案都蓄势待发。

概念与约束

int main() {
    std::list<int> l = {1, 2, 3};
    std::sort(l.begin(), l.end());
}

上面的例子会产生不可读的编译错误,因为std::sort对排序容器必须提供随机迭代器,但std::list是不支持随机访问的。引入概念之后,我们可以对模板进行约束:

template<typename T>
requires Sortable<T>  // Sortable 是一个概念
void sort(T& c);

还有模块、合约范围协程事务内存等。

其他部分作者还没写完,就到这吧。

汇编学习

这一部分不是书里的内容,是在学C++右值引用的时候对一些细节不理解,但没找到相关的资料,只能深入汇编去看实现细节,这里也记录一下相关的信息:

ELF文件格式

ELF文件主要有四种类型

  1. 可重定位文件(Relocatable File),可以用来和其他目标文件链接,即xx.o文件。
  2. 可执行文件(Executable File),规定了exec() 如何创建程序的进程印象。
  3. 共享目标文件(Shared Object File),可以和其他的可重定位文件一起链接成另一个目标文件;链接器可以将它与某个可执行文件组合,创建成可执行文件,即xx.so。
  4. 内核转储(core dumps),存放当前进程执行上下文,用于dump信号触发。

ELF提供两种视图,分别是链接视图和执行视图:

链接视图:以节(section)为单位,在连接时用到的视图。

执行视图:以段(segment)为单位,在执行时用到的视图。

可以这样来理解,在链接时,会将文件的相同的节组成一个段,在执行时把对应的段载入内存即可。

比较重要的一些节:

系统固定的section

  1. .text 代码段
    可以通过objdump -d 反汇编查看ELF文件代码段的内容。
  2. .strtab/.shstrtab 字符串表
    所有使用到的字符串都放在这里,以’\0’分隔。
  3. .symtab 符号表
    在链接的过程中需要把多个不同的目标文件合并在一起,不同的目标文件相互之间会引用变量和函数。在链接过程中,我们将函数和变量统称为符号,函数名和变量名就是符号名。
    这里的每个符号对应一个地址。
  4. .eh_frame / .eh_frame_hdr
    在调试程序的时候经常需要进行堆栈回溯,早期使用通用寄存器(ebp)来保存每层函数调用的栈帧地址,但局限性很大。后来现代Linux操作系统在LSB(Linux Standard Base)标准中定义了一个.eh_frame section,用来描述如何去unwind the stack。
    GAS(GCC Assembler)汇编编译器定义了一组伪指令来协助eh_frame生成调用栈信息CFI(Call Frame Information)。
  5. 重定位表(.relname)
    链接器在处理目标文件时,需要对目标文件中的某些部位进行重定位,即代码段和数据中中那些绝对地址引用的位置。对于每个需要重定位的代码段或数据段,都会有一个相应的重定位表。比如”.rel.text”就是针对”.text”的重定位表,”.rel.data”就是针对”.data”的重定位表。

GOT是全局偏移表( Global Offset Table),用于存储外部符号地址;PLT是程序链接表(Procedure Link Table),用于存储记录定位信息的额外代码。

寄存器

常用寄存器:

EAX:一般用作累加器
EBX:一般用作基址寄存器(Base)
ECX:一般用来计数(Count)
EDX:一般用来存放数据(Data)
ESP:一般用作堆栈指针(Stack Pointer)
EBP:一般用作基址指针(Base Pointer)
ESI:一般用作源变址(Source Index)
EDI:一般用作目标变址(Destinatin Index)

其中esp是栈顶,但是低地址;ebp是栈底,是高地址。

关于x86平台寄存器使用的一些约定

  • 当该函数是处于调用者角色时,如果该函数执行过程中产生的临时数据会已存储在%eax,%edx,%ecx这些寄存器中,那么在其执行call指令之前会将这些寄存器的数据写入其栈帧内指定的内存区域,这个过程叫做调用者保存约定(英文原名称:Caller Save)
  • 当该函数是处于被调用者角色时,那么在其使用这些寄存器%ebx,%esp,%edi之前,那么该函数会保存这些寄存器中的信息到其栈帧指定的内存区域,这个过程叫被调用者保存约定
  • %eax总会被用作返回整数值。
  • %esp,%ebp总被分别用着指向当前栈帧的顶部底部,主要用于在当前函数推出时,将他们还原为原始值

X86_64平台的约定

相比于寄存器而言,存储器的访问太慢了,因为X86_64有16个通用寄存器,当函数调用时少于6个参数是直接通过寄存器来存储参数的,多于6个还是依然通过入栈实现。

所以需要注意两点:

  • 为了效率,函数参数尽量少于6个;
  • 寄存器只有64位,传递较大参数时,尽量使用指针。

帧栈原理

32位帧栈

主要依靠的汇编指令:call和ret。前者跳转到函数入口执行,后者处理函数返回。

call _func做了两件事:

  1. pushl %eip // 保存下一条指令的地址,用于函数返回继续执行
  2. jmp _func // 跳转到函数_func

ret指令的作用相当于

  1. popl %eip // 将栈中的地址复制到eip寄存器中,返回前一个函数的上下文执行

leave指令相当于下面两条:

  1. moveq %rbp, %rsp // 将现在的栈底赋值给栈顶
  2. popq %rbp // 将前一个栈底弹出

x86_64帧栈

通常情况下,x86_64函数简化了帧栈,不再将x86中的%rbp寄存器作为栈底寄存器,只是用%rsp记录当前栈顶。唯一写入栈的操作就是执行到call时会push返回地址 8字节,但是以下情况需要用到帧栈:

  1. 局部变量太多,64位寄存器处理不过来;
  2. 局部变量中存在数组或者结构体(或者叫做类类型)的变量;
  3. 使用取址操作符&就计算局部变量的的内存地址;
  4. 调用另外一个超过6个参数的函数;
  5. 需要在修改它们之前保存被调用者保存寄存器的状态;

栈回溯

在早期技术中,栈回溯是通过(%ebp)获得上一个栈的地址,再对上一个地址取值就是上上个地址;但现代Linux放弃了这个技术,节省出了一个ebp寄存器,原有的栈调用被放在eh_frame这个专用于存储栈回溯相关的段中。

CFI伪指令生成在汇编文件中,根据链接选项(是否开启debug以及是否使用eh_frame段)来确定指令的执行内容,形式如下:

cfi_startproc
pushl %ebp
.cfi_def_cfa_offset 8
.cfi_offset ebp, -8

CFI(calling frame info)的作用是出现异常时stack的回滚(unwind),回滚的过程是一级级CFA往上回退,直到异常被catch。

其中eh_frame是编译完就静态生成好的,这个点困扰了我好久,现在大概理解了回溯的要点,eh_frame是根据汇编代码生成的一张表,运行时汇编对cfi的修改实际上就是在改动eh_frame中的索引。

引用:

https://zhuanlan.zhihu.com/p/286088470

https://blog.csdn.net/a568921915/article/details/103427976

https://zhuanlan.zhihu.com/p/288636064

https://stackoverflow.com/questions/7534420/gas-explanation-of-cfi-def-cfa-offset

https://www.jianshu.com/p/e7e21a51093e

https://zhuanlan.zhihu.com/p/290689333

https://zhuanlan.zhihu.com/p/302726082

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注