C/C++代码中积累的语法

C/C++中的数据类型转换

数据类型自动转换 当不同类型的变量同时运算时就会发生数据类型的自动转换,以常见的 char、short、int、long、float、double 这些类型为例,如果 char 和 int 两个类型的变量相加时,就会把 char 先转换成 int 再进行加法运算,如果是 int 和 double 类型的变量相乘就会把 int 转换成 double 再进行运算。

C语言中的强制类型转换

前面说了自动转换,从这里开始聊聊强制类型转换,需要强制类型转换往往程序不那么智能了,需要人工进行干预。比如把一个int 类型的变量赋值给 char 类型的变量,或者说把两个 int 相乘时可能会得到一个很大的数,所以需要先把 int 强制转换成 double 计算防止溢出。C++中的强制类型转换

C++中的强制类型转换

在C++语言中新增了四个用于强制类型转换的关键字,分别是 static_cast、 dynamic_cast, const_cast、 和 reinterpret_cast,使用语法为 xxxx_cast<new_type_name>(expression)。

static_cast

这个关键字的作用主要表现在 static 上,是一种静态的转换,在编译期就能确定的转换,可以完成C语言中的强制类型转换中的大部分工作,但需要注意的是,它不能转换掉表达式的 const、volitale 或者 __unaligned 属性。

dynamic_cast

从名字上看,这个关键字与 static_cast 的静态转换是对立的,这是一个“动态”转换函数,只能对指针和引用的进行转换,并且只用于类继承结构中基类和派生类之间指针或引用的转换,可以进行向上、向下,或者横向的转换。 相比于 static_cast 的编译时转换, dynamic_cast 的转换还会在运行时进行类型检查,转换的条件也比较苛刻,必须有继承关系的类之间才能转换,并且在基类中有虚函数才可以,有一种特殊的情况就是可以把类指针转换成 void* 类型。

const_cast

一种类型转换符,一种移除指针或引用的const或 volatile限定符

reinterpret_cast

它被用于不同类型指针或引用之间的转换,或者指针和整数之间的转换,是对比特位的简单拷贝并重新解释,因此在使用过程中需要特别谨慎,比如前面提到的一个例子,static_cast 不能将 int* 直接强转成 char*,使用reinterpret_cast就可以办到。

C/C++ 中 volatile 关键字详解

volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改,比如:操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。声明时语法:int volatile vInt; 当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。而且读取的数据立刻被保存。

C++中的inline关键字

C++中的inline关键字主要用于向编译器建议将函数或变量进行内联展开 核心目的: 减少函数调用开销(压栈,跳转,返回),提高小型,频繁调用函数的执行效率 -> 适用于函数体小 工作原理: 编译期间尝试将函数体代码直接插入到每一个调用处,类似"赋值"

优点: 提升性能,类型安全,易于调试(相比宏),可替代宏定义 缺点: 可能导致代码膨胀,从而降低缓存命中率 -> 适用于复杂函数,递归函数或虚函数

内联展开: 当编译器处理一个被建议内联的函数时,它会尝试将该函数体直接赋值粘贴到每一个调用它的地方

缓存命中率: 衡量系统效率的一个关键指标 , 计算公式: 缓存命中次数 / 总访问次数 * 100

C++ override关键字

是一个显示标识符,用于明确告诉编译器,派生类的这个成员函数意图重写(Override)基类的虚函数

当你使用override 关键字时,编译器会进行严格检查,确保派生类中的函数缺失成功重写了基类的虚函数,如果没有找到匹配的基类虚函数,编译器会直接报错

std::optional

用于表达: 可能有值,也可能没有值,std::optional 是现代 C++ 中表达“可选值”的 标准、安全、清晰 的方式 主要成员函数和操作

std::atomic

C++11 引入的模板类 std::atomic = 多线程环境下的"安全锁变量",让多个线程同时修改一个数时不会出错

perror()函数

perror()是C语言标准库中的一个错误处理函数,用于将错误信息输出到标准错误流

函数原型

#include <stdio.h> void perror(const char *str); 功能说明: 参数: str是一个自定义的提示字符串 输出格式: str:系统错误描述信息\n 输出位置: 标准错误流

工作原理: perror会根据全局变量errno的当前值,查找对应的系统错误描述信息,并与你提供的字符串组合输出

异步线程

// 1. 线程创建和lambda表达式 thread th_process(&rs{ while(ros::ok()){ ros::spinOnce(); } });

// 2.线程分离 th_process.detach();

// 3. 主线程循环 while(ros::ok()) { rs.Prorgess(); // 注意:可能是Progress的拼写错误 }

数据类型

size_t:

size_t 是一种在 C 和 C++ 编程语言中广泛使用的数据类型,通常用于表示对象的大小

  1. 定义 size_t 是一种无符号整数类型,定义在多个标准库头文件中,例如 <stddef.h>、<stdio.h>、<stdlib.h> 等。它的大小取决于目标平台的架构: 在 32 位系统中,size_t 通常是 32 位无符号整数(unsigned int)。 在 64 位系统中,size_t 通常是 64 位无符号整数(unsigned long 或 unsigned long long)。
  2. 用途 size_t 的主要用途是表示内存大小或对象的大小,例如: 在函数中表示数组或缓冲区的大小,如 malloc、calloc、realloc、memcpy 等函数的参数。 用于存储对象的字节大小,例如 sizeof 运算符的结果。
  3. 为什么使用 size_t 跨平台兼容性:size_t 的大小会根据目标平台自动调整,确保代码在不同架构(如 32 位和 64 位系统)上都能正确运行。 安全性:由于 size_t 是无符号类型,它可以避免在处理大小时出现负值,从而减少潜在的错误。 语义清晰:使用 size_t 明确表示变量用于表示大小,使代码更具可读性。

智能指针

std::unique_ptr: 独占所有权 1.std::unique_ptr是一种独占所有权的智能指针,确保同一时间只有一个unique_ptr拥有对象的所有权.它不可复制,但可以通过std::move转移所有权 使用场景: 管理不需要共享的动态资源,如局部动态对象,工厂函数返回值 注意: unique_ptr几乎零开销,性能与裸指针相当,是多数情况下的首选

2.std::shared_ptr:共享所有权 std::shared_ptr通过引用计数机制管理共享所有权.多个shared_ptr可以指向同一对象,当最后一个shared_ptr被销毁时,对象才会被释放 循环引用问题

3.std::weak_ptr: 弱引用(不拥有) std::weak_ptr是一种不控制对象生命周期的智能指针,它指向由shared_ptr管理的对象,但不增加引用计数,主要用于解决而不影响其生命周期,或解决循环引用 使用场景: 观察shared_ptr管理的对象而不影响其生命周期,或解决循环引用

构造函数:

默认构造函数: 有参构造函数: 略

拷贝构造函数:

拷贝构造函数是一种特殊的构造函数,用于创建一个新对象作为现有对象的副本

默认拷贝构造函数:

如果你不定义拷贝构造函数,编译器会自动生成一个

编译器对待拷贝构造函数和普通构造函数的不同:

1.调用时机; 普通构造函数的调用: 编译器明确直到调用普通构造函数 拷贝构造函数的自动调用: 编译器自动判断调用拷贝构造函数的场景

2.自动生成规则不同: 普通构造函数的生成规则: 如果没有声明任何构造函数,编译器会自动生成默认构造函数 拷贝构造函数的生成规则: 即使声明了其他构造函数,编译器仍会生成拷贝构造函数

深拷贝vs浅拷贝

浅拷贝: 仅复制指针的值(内存地址) 深拷贝: 为新对象分配新内存,并复制指针所指的内容

class Example { int x; std::string name; int* data; public: Example(int val) : x(val), data(new int[10]) {}

// 即使声明了其他构造函数,编译器仍会生成拷贝构造函数
// 除非显式声明或删除

};

Example e1(10); Example e2 = e1; // 调用编译器生成的拷贝构造函数(浅拷贝!危险!)为什么危险? 危险的原因: 编译器生成的默认拷贝构造函数进行的是浅拷贝 这会导致多个对象共享同一块动态内存,从而引发一系列严重的问题

1.重复释放导致程序崩溃: 当e1和e2的生命周期结束时,它们的析构函数会被自动调用。由于你没有自定义析构函数,编译器也会被自动调用,由于e1的data和e2的data指向的是同一块内存,这块内存被释放.紧接着e2析构时,它会尝试再次释放同一块已经归还给系统的内存,这会导致未定义行为,通常直接造成程序崩溃

2.数据意外修改: 修改一个会改另一个

3.悬挂指针 e1被销毁了,e2还在,但是它的data成员已经指向无效内存地址的悬挂指针

解决方法; 使用深拷贝

class Example { int x; std::string name; int* data; size_t size; // 记录数组大小,确保拷贝正确

public: Example(int val) : x(val), size(10), data(new int[10]) {}

// 1. 深拷贝构造函数
Example(const Example& other)
    : x(other.x), name(other.name), size(other.size), data(new int[other.size]) { // 分配新内存
    // 复制数据内容
    std::copy(other.data, other.data + other.size, data);
}

// 2. 深拷贝赋值运算符
Example& operator=(const Example& other) {
    if (this != &other) { // 关键:防止自赋值 (e.g., e1 = e1)
        delete[] data; // 释放自己的旧资源

        x = other.x;
        name = other.name;
        size = other.size;
        data = new int[size]; // 分配新内存
        std::copy(other.data, other.data + size, data); // 复制数据
    }
    return *this;
}

// 3. 析构函数
~Example() {
    delete[] data; // 安全释放自己独占的内存
}

};

左值右值:

std::move的本质 作用:将左值转换为右值引用

// std::move 的简化实现 template typename std::remove_reference::type&& move(T&& arg) noexcept { return static_cast<typename std::remove_reference::type&&>(arg); } 特别注意: 当一个对象使用std::move后,就表示你承诺不再使用它(除非重新赋值),因为它的资源可能已经被移走,状态是未定义的: 引发难以调试的bug

左值引用(T&) (是别名,旨在共享和操作持久对象) 绑定对象: 左值(有标识,有持久性的对象)

int& ref = variable; 右值引用(T&&) (旨在高效转移临时对象的资源) 绑定对象: 右值(临时,即将消亡的对象)

int&& ref = 10; int&& ref = std::move(variable); // 或者

RVO和NRVO(编译器优化)

这两种优化的核心思想都是"偷梁换柱": 编译器通过在调用出为接收返回值的对象分配内存,然后将这块内存的地址作为一个隐藏参数传递给被调函数.被调函数会直接在这块预先分配好的内存上构造本应返回的对象,从而完全避免了中间临时对象的创建和拷贝

RVO(返回值优化) 优化对象: 返回匿名临时对象

NRVO(具名返回值优化) 优化对象: 返回具名的局部对象

tr1::function/std::function

function是一个通用的,多态的函数封装器,它是一个类模板,可以容纳(保存,拷贝,调用)几乎所有类型的可调用对象

简单来说: 有点像一个函数指针的超级升级版

一个普通的函数指针只能指向一个特定签名的函数.而function对象可以容纳任何具有兼容签名的可调用实体 例如: 1.普通函数 2.函数对象(重载了operator()的类,即Functor) 3.Lambda表达式(C++11引入,但TR1时期还没有) 4.类的成员函数(需要配合st d::bind或tr1::bind) 5.类的数据成员指针(需要配合std::bind或tr1::bind)

hypot()函数

作用: 计算直角三角形的斜边长 -> 勾股定理

计算二维或三维空间中的距离(例如,点 (x, y) 到原点 (0, 0) 的距离)。 在图形学、物理模拟、机器学习、信号处理等任何需要用到向量长度的领域。 避免在手动计算 sqrt(xx + yy) 时可能出现的数值计算问题。

#include <math.h> // C #include // C++

double hypot(double x, double y); float hypotf(float x, float y); // 单精度版本 long double hypotl(long double x, long double y); // 长双精度版本

C语言中关于内存的一些函数

malloc() - 内存动态分配

典型用途: 动态数据结构

int* arr = (int*)malloc(10 * sizeof(int));

realloc() - 内存重新分配

典型用途: 动态数组扩容 调整已分配内存块的大小(可扩大或缩小)

int* arr = (int*)malloc(5 * sizeof(int)); // 使用arr…

// 扩容到10个元素 int* new_arr = (int*)realloc(arr, 10 * sizeof(int)); if (new_arr == NULL) { // 注意:如果realloc失败,原指针ptr依然有效! free(arr); // 清理原内存 perror(“realloc failed”); exit(1); } arr = new_arr; // 使用新指针

// 缩小到3个元素 arr = (int*)realloc(arr, 3 * sizeof(int)); // 注意:缩小通常成功,但可能返回不同的地址

memset() - 内存填充

典型用途: 清零,初始化 将内存块的每个字节设置为指定值

struct Student {int id;char name[20];float grade;};

struct Student s;memset(&s, 0, sizeof(struct Student));

char buffer[1024];memset(buffer, ‘A’, 100); // 前100字节设为’A’memset(buffer + 100, 0, 924); // 其余部分清零

memcpy() - 内存复制 - 不检查重叠

典型用途: 高效数据复制

从源内存地址复制指定字节数到目标内存地址

int src[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};int dest[10];memcpy(dest, src, sizeof(src));

与strcpy()的区别 strcpy(dest1, src); // 遇到’\0’停止,dest1 = “Hello” memcpy(dest2, src, 11); // 复制11个字节,包括中间的’\0’

memmove - 内存复制 - 检查重叠

典型用途: 重叠内存复制

// 将前13个字符复制到从第10个字符开始的位置 memmove(buffer1 + 9, buffer1, 13);

calloc - 分配内存

典型用途: 需要零初始化的分配

void* calloc(size_t num, size_t size); 分配num个size字节的连续内存块,并初始化为全0。

int* numbers = (int*)calloc(10, sizeof(int));

封装普通函数

#include #include

int add(int a, int b) {return a + b;}

int main() { std::function<int(int, int)> func;// 将普通函数 func = add; std::cout « “Result: " « result « std::endl;

return 0;

}

封装函数对象

#include #include

struct Multiplier {int factor;Multiplier(int f) : factor(f) {}int operator()(int value) {return value * factor;}};

int main() { std::function<int(int)> func; Multiplier times5(5); func = times5;

std::cout << func(10) << std::endl;

}

std::future是一个模板类

std::future 是一个模板类,尖括号里的 int 表示这个异步任务最终会返回一个整数。 它是连接"主线程"和"后台工作线程"的桥梁。后台线程负责计算,算完把结果填进去;主线程拿着future,等需要的时候取出来

eg. 你去餐厅点餐后,服务员给你的那张取餐小票 当你点餐(调用enqueue)时: 厨师(线程池里的线程)开始在后台做饭。饭还没做好,但服务员 立即给了你一张小票

这张小票就是std::future: 它代表了一个未来会产生的结果。现在它是空的,但只要你拿着它,将来饭做好了,你就能取到饭

.get()方法: 这就是你去柜台凭票取餐的动作 如果饭做好了,你立马拿走 如果饭还没做好,你就要在柜台死等(阻塞),知道饭做好为止

sprintf_s和sprintf函数的区别

            strcpy_s - 安全字符串拷贝
            将源字符串src(包括结尾的'\0')完整复制到目标缓冲区dest中.
            如果目标缓冲区太小而无法包容源字符串,不会发生缓冲区溢出
            将 dest[0] 设置为 '\0'(空字符串)
            调用错误处理函数(默认会触发断言并终止程序,或可通过 _set_invalid_parameter_handler 自定义)
            返回非零错误码(EINVAL 或 ERANGE)


            sprintf_s – 安全格式化输出到字符串
            将格式化数据(类似 printf)写入到字符串缓冲区 buffer 中,
            并在末尾自动添加 '\0'。会检查缓冲区是否足够大,
            如果格式化后的字符串(包括结尾空字符)超过 buffer_size,则:

image.png

CPP读书笔记

C/C++primer plus

第十章对象和类

面向对象编程

首先从用户的角度考虑对象——描述对象所需的数据以及描述用户与数据交互所需的操作

默认构造函数 通过函数重载,可以创建多个同名的析构函数,条件是每个函数的特征标都不同。 如果没有提供任何构造函数,则C++将自动提供默认构造函数 如果提供了非默认构造函数,但没有提供默认构造函数,则直接声明会出错。

析构函数 不应该在代码中显示地调用析构函数 1.如果创建的是静态存储类对象,则析构将在程序结束时自动被调用 2.如果创建的是自动存储类对象,则其析构函数将在代码块时自动被调用 3.如果对象是new创建的,则它将驻留在栈内存或自由存储区中。

成员名和参数名 在数据成员名使用m_前缀或加_后缀

C++11列表初始化 只要提供与某个构造函数的参数列表匹配的内容,并用构造函数将它们括起。

const成员函数 例如 void show() const保证函数不会修改调用对象

this指针 ?没看懂

对象数组 初始化对象数组的方案 首先使用默认构造函数创建数组对象,然后花括号中的构造函数将创建临时对象,然后将临时对象的内容复制到相应的元素中。

类作用域 在类中定义的名称的作用域都为整个类,作用域为整个类的名称只在类该类中是已知的,在类外是不可知的。因此可以在不同类中使用相同的类成员名而不会引起冲突

作用域为类的常量 行不通,因为声明类只是描述了对象的形式,并没有创建对象。因此,在创建对象前,讲没有用于存储值的空间。

作用域内枚举 enum class C++还提高了作用域馁枚举的类型安全。但在有些情况下,常规枚举将自动转换为整型,如将其赋值给int变量或用于比较表达式,但作用域内枚举不能隐式地转换为整型。

抽象数据类型 略

第十一章使用类

不要害怕犯错误,因为在解决问题的过程中学到的知识,比生搬硬套而不犯错误要多得多. 学习C++的 运算符重载 不要返回指向局部变量或临时变量的引用 局部变量:在函数结束时被销毁,内存被回收 临时变量:在创建它们的表达式结束时销毁 返回的引用指向无效内存:这些变量在函数返回后不再存在导致引用指向"“垃圾数据

添加加法运算符 operator+()

重载限制 1.重载后的运算符必须有一个操作数是用户定义的类型 2.使用运算符时不能违反运算符原来的句法规则 3.不能 创建新运算符 4.不能重载下面的运算符 略 5.= () [] = ->这四个运算符只能通过成员函数进行重载

友元 友元有三种: 友元函数,友元类,友元成员函数

友元函数:一类特殊的非成员函数可以访问类的私有成员,他们被称为友元函数。 创建友元 第一步在原型前加上friend关键字 第二步编写函数定义。因为他不是成员函数,因此不能用成员函数来调用。 虽然是在类声明种声明的,但它不是成员函数,因此不能使用成员运算符来调用。 虽然不是成员函数,但它与成员函数的访问权限相同。

只有类声明可以决定哪一个函数是友元,因此类声明任然控制了哪些函数可以访问私有数据。

Effective C++

1.让自己习惯C++

条款01: 视C++为一个语言联邦

条款02: 尽量以const,enum,inline替换#define

条款03: 尽可能使用const

条款04: 确认对象被使用前已被初始化

2.构造/析构/赋值运算

条款05: 了解C++默默编写并调用哪些函数

条款06: 若不幸使用编译器自动生成的函数,就该明确拒绝

条款07:为多态基类声明virtual析构函数

C++中通过基类指针或引用删除派生类对象时,编译器需要根据析构函数是否为virtual来决定调用哪个析构函数: 静态绑定: 当基类析构函数非虚时,编译器根据指针的静态类型(即基类类型)在编译期就确定调用基类的析构函数.它无法感知指针的实际指向的派生类对象,因此不会调用派生类的析构函数 动态绑定: 当基类析构函数为virtual时,C++通过虚函数表(vtable)机制实现运行时多态.每个包含虚函数的类都有一个vtable,其中存放着虚函数的地址.派生类会覆盖基类的虚析构函数项.当删除对象时,系统会根据对象的实际类型查找vtable,找到并调用派生类的析构函数.派生类的析构函数执行完毕后,会自动调用基类的析构函数,从而形成完整的析构链 -> 即使派生类的析构函数没有显式地写上virtual,派生类的析构函数便会自动成为析构函数

vptr -> 虚指针 vtable -> 虚函数表

vptr指针指向一个由函数指针构成的数组,称为vtbl (主要用来在运行期决定哪一个virtual函数该被调用)

析构函数运作方式,最深层派生的那个class其析构函数最先被调用,然后是其每一个base class的析构函数被调用

条款08: 别让异常逃离析构函数

条款09: 绝不在构造和析构过程中调用virtual函数

条款10: 令operator= 返回一个reference to *this

条款11: 在operator= 中处理"自我赋值”

3.资源管理

条款13 : 以对象管理资源

资源: 内存,文件描述器,互斥锁,图形界面中的字型和笔刷,网络sockets 无论哪一种资源,重要的是,当你不再使用它的时候,必须将它还给系统

无端地将所有classes的将所有classes的析构函数声明为virtual,就像从未声明它们为virtual,都是错误的. ——> 添加vptr会大量添加对象的大小

函数内多重回传路径: 如果一个函数声明了非void的返回类型,那么它就必须在所有可能的执行路径上都提供一个返回值.否则,编译器会报错

copying函数: -> 通常指的是对象拷贝相关的特殊成员函数,主要用于管理对象的复制过程,它们对于确保资源正常管理,避免内存泄漏和数据混乱至关重要

1.拷贝构造函数: 拷贝构造函数用于用一个已存在的对象来初始化一个新对象

2.拷贝赋值运算符: 用于在对象已存在的情况下,将同一个类对象的值赋值给它.他通过operator=来实现,定义了当使用赋值操作=时对象的行为

class MyClass { public: // … // 拷贝赋值运算符 MyClass& operator=(const MyClass& other) { // 赋值操作逻辑 if (this != &other) { // 1. 自我赋值检查 // 2. 释放当前对象的资源(如果需要) // 3. 复制 other 对象的值 value = other.value; } return *this; // 4. 返回当前对象的引用(为了支持链式赋值) } private: int value; // … 其他成员 };

因为return和异常可能导致delete没有被执行,资源没有被释放(我们泄漏的不只是内含投资对象的那块内存,还包括投资对象所保存的任何资源) 把资源放进对象内,我们便可依赖C++的"析构函数自动调用机制"确保资源被释放

以对象管理资源的两个关键想法: 1.获得资源后立即放进管理对象 -> (资源取得时机便是初始化实际) RAII 2.管理对象运用析构函数确保资源被释放

注意 auto_ptr被销毁时会自动删除它所指之物,别让auto_ptr同时指向同一对象,否则对象会被删除一次以上,程序会引发未定义行为

RCSP (引用计数型智能指针): RCSP持续追踪共有多少对象指向某笔资源,并在无人指向它的时自动删除该资源.RCSP提供的行为类似垃圾回收,不同的是RCSP无法大伯环状引用,例如两个起始已经没有被使用的对象彼此互指,因而好像还处在被使用的状态

4.设计与声明

条款19: 设计class犹如设计type

C++就像在其他OOP(面向对象编程)语言一样,当你定义一个新class,也就定义了一个新的type

1.新type的对象应该如何被创建和销毁? 会影响到你的构造函数和析构函数以及内存分配函数和释放函数

2.对象的初始化和对象的赋值该有什么样的差别? 因为它们对应不同的函数调用

3.新type的对象如果passed by value(以值传递),意味着什么?

4.什么是新type的"合法值”?

5.你的新type需要配合某个继承图系(Inheritance graph)吗? 如果你允许其他classes继承你的class,那会影响你所声明的函数——尤其时析构函数——-是否为virtual

6.你的新type需要什么样的转换?

7.什么样的操作符和函数对此type而言是合理的?

8.什么样的标准函数应该被驳回? 那些正是你必须声明为private者

9.谁该取用新type的成员?

10.什么是新type的"未声明接口"(undeclared interface)? 它对效率,异常安全性以及资源运用(例如多任务锁定和动态内存)提供何种保证? 你在这些方面提供的保证将为你的class实现代码加上响应的约束条件

11.你的新type有多么一般化? 或许你其实并非定义一个新type,而是定义一个新type,而是定义一整个type家族.果真如此你就不该定义一个新class,而是定义一个新的class template

12.你真的需要一个新type吗? 如果只是定义新的deriver class 以便为既有的class添加机能,那么说不定一或多个non-member函数或templates,更能够到达目标

条款20: 宁以pass-by-reference-to-const 替换 pass-by-value

尽量以pass-by-value-to-const 替换 pass-by-value. 前者通常比较高效,并可避免切割问题 切割问题: 发生在将派生类对象按值传递给期望基类参数的函数时,派生类特有的部分被切割掉,只保留基类部分

条款21: 必须返回对象时,别妄想返回其reference

5.实现

6.继承与面向对象设计

条款32: 确定你的public继承塑膜出is-a关系

Public 继承 意味着is - a,适用于base classes身上的每一件事情一定也适用于derived classes身上,因为每一个derived class对象也都是一个base class对象

条款33: 避免遮掩继承而来的名称

内层作用域的名称会遮掩外围作用域的名称

编译器遭遇名称x,现在local作用域内查找是否有什么东西带着这个名称.如果找到就不再找其他作用域,否则就逐层的向外查找,,直到查找到global scope

条款34: 区分接口继承和实现继承

函数接口继承

函数实现继承

接口继承和实现继承不同,在public继承之下,derived classes总是继承base class的接口 Pure virtual函数只具体指定接口继承 简朴的impure virtual函数具体指定接口继承及缺省实现继承 non-virtual函数具体指定接口继承以及强制性实现继承

条款35: 考虑virtual函数以外的其他选择

tr1::function

条款36: 绝不重新定义继承而来的non-virtual函数

non-virtual函数: 调用版本取决于指针/引用的静态类型(编译时决定) - 静态绑定 virtual函数: 调用的版本取决于对象的实际类型(运行时决定) - 动态绑定

1.破坏了is-a关系 共有继承应该意味着"派生类对象是一个基类对象" 但对于non-virtual函数,同样的对象表现出不同的行为,这违背了"is-a"原则 2.导致不一致的行为 3.违反里氏替换原则(LSP) 派生类应该能够替换基类而不影响程序的正确性 重新定义non-virtual函数破坏了这个原则

non-virual函数的定义: 当你在基类中将一个函数声明为non-virtual,你实际上是在向所有派生类传达: 这个函数的行为是不可变的,不可定制的。它对所有派生类对象都有相同的意义和实现

virtual函数的定义: 当你在基类中将一个函数声明为virtual,你实际上是在说: 这个函数的行为是可以被定制的.各个派生类可以根据自己的需要来重写它

条款37: 绝不重新定义继承而来的缺省参数值

绝对不要重新定义一个继承而来的缺省参数值,因为缺省参数值都是静态绑定,而virtual函数—你唯一应该覆写的东西—却是动态绑定

7.模板与泛型编程

8.定制new 和 delete

9.杂项讨论

深度求索C++对象模型

第一章关于对象

关于对象是整本书的基石.这一章主要为了确立大局观: 当你从C语言的struct走向C++的class时,为了获得面向对象的特性,你到底付出了什么代价? 底层模型又是长什么样的?

1.封装的额外成本: 其实是零

C++ 在布局和存取时间上的主要额外开销,仅仅是由虚拟(Virtual)机制带来的(包括虚函数和虚拟继承)。普通的非静态数据成员(Non-static data members)在内存中的布局和 C 语言的 struct 完全一样。普通的成员函数无论是声明多少个,都不会占用对象实例哪怕 1 个字节的内存空间!

2.C++对象模型

对象内部: 只有非静态数据成员(Non-static data members),以及为了支持多态而可能被编译器偷偷安插的虚函数表指针(vptr)。

对象外部(全程序共享): 所有的静态数据成员(Static data members)、普通的成员函数(Non-static function members)和静态成员函数(Static function members)都被放在对象之外。

虚函数表: 每个包含虚函数的类都会产生一个独立的虚函数表,里面存放着该类所有虚函数的地址,以及支持运行时类型信息(RTTI)的 type_info 对象。

3.多态的威力与限制

指针与引用是前提: C++ 仅仅通过指针(Pointers)和引用(References)来支持多态。当你用一个基类指针指向派生类对象时,编译器不会在编译期写死调用的函数,而是在运行期通过 vptr 找到真正的函数去执行。

对象切割(Slicing): 如果你直接把一个派生类对象(而不是指针或引用)赋值给一个基类对象,多态就会瞬间失效。编译器会把派生类对象中多出来的部分直接“切掉”,只保留基类部分的数据进行按位拷贝。这就是为什么面向对象的多态绝不能通过传值(Pass by Value)来实现。

4.面向对象(OO)与基于对象(OB)

Object-Oriented (OO): 包含继承和多态。由于具体对象的类型在编译期无法确定,所以必须在**运行期(Runtime)**通过指针或引用来进行动态绑定。设计非常灵活,但存在 vptr 内存占用和虚函数间接访问的性能开销。

Object-Based (OB): 只使用了类的封装特性,可能包含非多态的数据类型(比如原生的 string 类)。没有任何虚函数。所有的解析和函数调用在**编译期(Compile-time)**就完全确定了(静态绑定)。运行速度极快,内存紧凑,但缺乏面向对象的动态扩展性。

第二章构造函数语意学

编译器到底在我们的构造函数背后偷偷做了哪些手脚?

1.默认构造函数的真相

编译器只有在真正需要的时候,才会合成出一个nontrivial(非平凡的/有用的) 默认构造函数.如果不符合条件,编译器根本懒得管,它什么都不会生成

以下四种情况: 1.带有默认构造函数的成员对象: 2.继承自带有默认构造函数的基类 3.带有虚函数的类 4.带有虚基类的类

2.拷贝构造函数与Bitwise Copy (The Copy Constructor)

当我们用一个对象去初始化另一个同类型对象时,默认情况下,C++ 编译器会展现出 Bitwise Copy Semantics(位逐次拷贝语意),也就是简单粗暴地把内存里的数据按位原封不动地复制过去。这种做法效率极高。

什么时候 Bitwise Copy 会失效? 当简单的按位拷贝会破坏程序逻辑时,编译器就必须合成出一个 nontrivial 的拷贝构造函数 来执行逐成员初始化(Memberwise Initialization)。同样也是四种情况:

1.成员对象带有拷贝构造函数。 2.基类带有拷贝构造函数。 3.类声明了虚函数: 这是最关键的考点!假设你有一个派生类对象,并把它拷贝给一个基类对象(发生切片)。如果执行按位拷贝,基类对象的 vptr 就会被错误地指向派生类的虚函数表,这会引发灾难。因此编译器必须介入,确保新对象的 vptr 指向正确的虚表。 4.类派生自虚拟继承体系。

3.程序与NRV优化

这一部分探讨了对象在按值传递和按值返回时,编译器在底层优化,最著名的就是NRV优化 编译器会在底层修改函数签名,直接把你外层用来接收返回值的那个对象的内存地址作为隐藏参数传进函数里。函数内部的代码直接在这块外部内存上构造对象。这样就完美消除了一次拷贝构造和析构的开销。

4.成员初始化列表的陷阱

必须使用初始化列表的情况 1.初始化一个Reference(引用)成员时 2.初始化一个const成员时 3.调用一个成员对象的构造函数,并且它拥有一组参数时 4.调用一个成员对象的构造函数,并且它拥有一组参数时

最大陷阱: 成员变量的初始化的顺序,完全是由它们在类中声明的顺序决定的

第三章Data语意学

当我们定义个C++类并实例化对象时,这个对象的数据在内存中到底是怎么排列的 -> 编译器在背后为了实现面向对象所做的脏活累活

1.一个C++对象到底多大?

空类(Empty Class): 如果你定义一个没有任何数据成员的空类,它实例化后的大小通常是1byte. 这是编译器为了保证该类的每一个对象在内存中都有一个独一无二的地址

内存对齐(Memory Alignment): 为了让CPU更高效地读取数据,编译器会在数据成员之间插入填充字节.比如一个char(1字节) 跟着一个int(4字节),它们可能总共占据8个字节

语言支持的额外开销(Overhead): 如果你的类声明了虚函数,或者使用了虚拟继承,编译器会在对象内部偷偷插入指针(比如指向虚函数表地vptr,或指向虚基类表地vptr)

2.数据成员的布局(Data Member Layout)

C++中的数据成员分为静态(static)和非静态(non-static)

Static 数据成员: 它们不存放在类的对象中,而是存放在程序的全局数据段。不管你创建了 100 个对象还是 0 个对象,静态数据成员永远只有一份实体。

Non-static 数据成员: 它们存放在每一个类的对象内部。 在同一个访问控制区(比如都在同一个 private: 下)内,成员在内存中的排列顺序与它们在类中被声明的顺序完全一致。 如果有多个访问控制区(比如先写了一段 public,又写了一段 private,再写一段 public),C++ 标准并不强制规定不同区域的先后顺序,但目前主流编译器通常还是按照声明的顺序连续存放在内存中。

3.继承对数据布局的影响

单一继承(不含虚函数): 派生类(Derived class)的对象包含了基类(Base class)的子对象。通常,基类的数据会被放在派生类对象的最前面(低地址处),然后接着放派生类自己的数据成员。

加上多态(添加了虚函数): 只要类继承体系中出现了虚函数,编译器就会在对象中安插一个虚函数表指针(vptr)。至于这个 vptr 放在对象的开头还是结尾,取决于具体的编译器(GCC 和 MSVC 的处理方式不同)。

多重继承: 如果类 C 同时继承了类 A 和类 B。在类 C 的对象中,会先存放 A 的子对象,紧接着存放 B 的子对象,最后存放 C 自己的成员。 这里有一个关键点: 如果你把一个 C 对象的地址赋值给一个 B 类型的指针,编译器必须在底层对指针的地址进行 偏移量调整(Offset adjustment),以确保指针指向的是 B 子对象的正确起始位置。

补充: cpp通过虚继承来解决菱形继承问题,菱形继承指的是两个派生类(如 B 和 C)共同继承同一个基类(A),然后又一个类(D)同时继承这两个派生类,导致 D 中包含两份 A 的子对象,访问 A 的成员时会产生二义性,且浪费内存。

虚拟继承: 菱形继承(A 派生出 B 和 C,D 同时继承 B 和 C)会导致 A 的数据在 D 中出现两份。虚拟继承就是为了解决这个问题。 在虚拟继承的底层实现中,编译器通常会使用指针(或者虚基类表偏移量)来追踪共享的那个基类(A)。这使得访问虚基类的数据变得相对间接,带来了一定的性能开销,但保证了数据的唯一性。

class A { public: int value; };

class B : virtual public A { }; // 虚继承 class C : virtual public A { }; // 虚继承

class D : public B, public C { }; // D 中只有一份 A

D d; d.value = 10; // 无二义性,直接访问共享的那一份 A

实现原理: 编译器会为虚继承的类生成一个虚基类指针(vbtr),指向虚基类表(vbtable) 表中记录了虚基类对象相对于当前对象地址的偏移量.通过这个偏移量,所有派生类共享同一个虚基类实例 因此D中的B和C不再各自保存独立的A,而是通过偏移量找到共同的A子对象

4.数据成员的存取

访问 Static 数据: 就跟访问普通的全局变量一样,在编译时地址就已经确定了,没有任何运行时的额外开销。

访问 Non-static 数据: 编译器会通过对象的首地址加上该成员在类中的 偏移量(Offset) 来计算出实际的内存地址。 如果是通过具体的对象(obj.x)访问,偏移量在编译期就完全确定了。 如果是通过指针或引用(ptr->x)访问,并且这个类涉及了虚拟继承,那么具体的偏移量可能要到运行期才能确定,这就产生了一定的延迟。

第四章Function语意学

图片 1