目录

C++ 特性

  1. 代码编译

    • 预编译:对源代码进行宏替换,编译头文件等,输出 .i 文件
    • 编译:将 .i 文件代码转换为汇编语言文件,进行词法/语义分析,错误检查等,生成 .s 文件
    • 汇编:汇编器将汇编语言文件 .s 翻译成机器语言,生成 .o 文件
    • 链接:链接目标文件和库文件,生成可执行文件 exe
  2. 内存分区

    程序运行时,代码、数据等都存放在不同的内存区域,这些内存做了逻辑划分,为:代码区、全局/静态存储区、栈区、堆区和常量区

    • 代码区(Code Segment):存放程序二进制代码,区域只读,防止程序运行过程中被意外修改,.text 段通常被映射到代码区

    • 全局/静态存储区(Global/Static Storage):全局变量、静态变量都存放在这个区域。在 C 语言中,全局变量分为初始化和未初始化,放在 .bss.data 段,而 C++ 中共同占用一块内存

    • 栈(Stack):存放局部变量、函数参数和返回地址,栈从高位地址向低位地址生长

    • 堆(Heap):动态分配内存的区域,如 malloc、new,内存需手动释放

    • 常量区(Constant Storage):该区域也是只读,存储常量,例如 char* c="abc" 字符串字面量,其中 abc 在常量区

      二进制程序的存储段有:① .text 存放程序机器代码 ② .data 存放已初始化的全局、静态变量 ③ .bss 存放未初始化的全局、静态变量 ④ .rodata 存放只读数据,如字面量常量

  3. 成员的初始化顺序

    可初始化列表初始化有关系,通常:

    基类的静态变量或全局变量,派生类的静态变量或者全局变量,基类的成员变量,派生类的成员变量

  4. TMP

  1. auto 关键字

    编译器能在编译期间自动推导出变量的类型,减少复杂类型的声明工作量。

  2. decltype 关键字

    可以用作非静态成员的推导。其中一些规则需要被注意,假设通过 decltype(exp) 获取类型:

    • ① 若 exp 为左值,或被括号 () 包围,那么返回类型为 exp 的引用
    • ② 若 exp 是函数调用,或是一个不被括号 () 包围的表达式,则返回类型和 exp 类型一致
    void main(){
        const Base obj;
        //带有括号的表达式
        decltype(obj.x) a = 0;  // obj.x 为类的成员访问表达式,符合推导规则②,a 的类型为 int
        decltype((obj.x)) b = a;  // obj.x 带有括号,符合推导规则①,b 的类型为 int&
        //加法表达式
        int n = 0, m = 0;
        decltype(n + m) c = 0;  // n + m 得到一个右值,符合推导规则②,推导结果为 int
        decltype(n = n + m) d = c;  // n = n + m 得到一个左值,符号推导规则①,所以推导结果为 int&
    }
  3. constexpr 关键字

    用于修饰常量表达式,它将在编译期间计算出结果。

    const 的区别:const 本质上仍然是变量,但强调其只读属性。在 C11 的标准定义中,将“常量”语义划分给了 constexpr。

  4. const 关键字

    const int a = 10;
    a = 20; // 编译错误,a 是只读变量,不能被修改
    

    设置一个变量的只读属性,但只是编译器层面的保证,仍然可以通过指针在运行时去间接修改这变量的值。也可以通过 const_cast 做类型强制转换,但不推荐。

    且编译器可能会将代码替换为常量,导致不调用访问常量的指令,因此用户修改后也不生效。

    const int* p;
    *p = 1; // 错误
    p = &x; // 正确
    // const 修饰 int,表示指针指向的内容是常量,值不能改,但指针指向可变
    int* const p;
    // const 修饰的是指针 p,表示指针本身是常量,指针不可变,值可变
    

    上面两种方法修饰的变量,可通过关注 const 修饰的对象来记忆,下面案例修饰成员函数:

    class A {
    public:
        int func() const {
            // 编译错误,不能修改成员变量的值
            m_value = 10;
            return m_value;
        }
    private:
        int m_value;
    };

    修饰函数的益处是,const 修饰的对象可以调用这些成员方法,默认 const 对象是不允许调用非 const 的成员方法的。

    要注意,const 的成员函数不能调用非 const 的成员函数,原因在于 const 的成员函数保证了不修改对象状态,但是如果调用了非 const 成员函数,那么这个保证可能会被破坏。

  5. nullptr 关键字

  6. noexcept 关键字

    可以用于修饰函数,不抛出异常;若被修饰的函数抛出异常,则程序会自动调用 std::terminat 方法中断执行。

  7. explicit 关键字

    防止类构造函数的隐式自动转换。

  8. override与final关键字

    标识符final修饰类表示该类无法被继承,修饰虚函数表示该函数无法被重写,组织函数重载。

  9. sizeof 关键字

    int func(char array[]) {
        printf("sizeof=%d\n", sizeof(array));
        printf("strlen=%d\n", strlen(array));
    }
    
    int main() {
        char array[] = "Hello World";
        printf("sizeof=%d\n", sizeof(array));
        printf("strlen=%d\n", strlen(array));
        func(array);
    }
    ------输出为-------
    sizeof=12
    strlen=11
    sizeof=8
    strlen=11

    可以观察到函数func内部打印的大小为8,这里涉及到数组退化为指针,数组作为函数参数时,实际传递的是指向数组首元素的指针。

    template <typename T, std::size_t N>
    void printSizeAndLength(const T (&arr)[N]) {
        std::cout << "Size of arr in function: " << sizeof(arr) << std::endl; // 计算数组的大小
        std::cout << "Length of arr: " << strlen(arr) << std::endl; // 计算字符串的长度
    }

    上述函数使用了模板函数,接受一个数组引用作为参数,此时内部sizeof计算数组大小时,不会退化为指针。

  10. volatile

防止编译器优化,确保每次访问变量从内存中读取,而不是寄存器或缓存中。

  1. 明确禁止/删除特殊成员函数
  1. lambdas

  2. 类型转换

    • static_cast:基本与 C 语言中的强转等价,它在编译时执行转换,在进行指针、引用类型转换时,需要自己保证合法性
    • dynamic_cast:用于父子类层次的安全类型转换,在运行时检查,因此更加安全。转换失败会返回空指针,且必须基类含有一个虚函数
    • const_cast:能删除对象的 const 属性,谨慎使用
    • reinterpret_cast:不进行类型安全检查,甚至能将指针转换为整数
  3. 右值引用

    左值(loactor value)意为存储在内存中、有明确存储地址(可寻址)的数据,而右值(read value),是可以提供数据值的数据(不一定可以寻址,例如存储于寄存器中的数据)。

    编译器不允许为右值建立引用,如int &c = 10; //错误 。又由于右值往往没有名称,因此使用它只能借助引用的方式,故 C11 引入了新的引用方式 && 表示右值引用。

    为什么使用右值引用?

    为了支持移动语义,右值引用能绑定到临时对象、表达式等右值上,从而在这些右值的对象的生命周期内窃取资源,避免昂贵的复制操作

  4. 移动语义

    移动语义指将其他对象(通常是临时对象)拥有的内存资源“移为已用” ,而不在进行内存拷贝,用来提高性能。如移动构造函数:

    Base(Base &&b){
        cout << "move construct!" << endl;
    }

    为了满足左值初始化的对象也完成移动构造,C11 引入了 std::move 函数,它能将左值强制转换成对应的右值。

  5. 引用限定符

    某些使用场景下,需要限制调用成员函数的对象类型,在 C11 中能在成员函数后添加&&&,从而限制调用者的类型。如:

    // 限定函数调用者必须是左值对象
    int getnum()&{
        return 0;
    }
    // 限定函数调用者必须是右值对象
    int getnum()&&{
        return 0;
    }
    // 引用限定符不适用于静态函数、友元函数
    

    由于 const 也能用于修饰类的成员函数,因此根据标准规定, const 必须位于引用限定符前面。同时 const & 修饰成员函数时,调用对象既可以是左值,也可以是右值,这里存在变化。

  6. 智能指针

    本质上是类模板包装了一个原始指针通过类封装实现对原始指针进行管理和操作,能解决原始指针在内存管理方面存在的问题,如内存泄漏、悬空指针等。

  7. 类型萃取

    它和模板特化在功能上似有功能重叠之处,但侧重点不同。其中,

    • 模板特化:适合为具体类型优化和改变行为,给特定类型提供定制化实现
    • 类型萃取能进行条件编译,选择不同的代码路径,能实现更为复杂的能力。例如,判断类型是否包含某个成员函数等等。

https://blog.csdn.net/qq_41854911/article/details/119657617

多态:“一个接口,多种实现”,允许同一个接口调用不同的实现方式。

多态行为:通过基类指针或引用调用派生类重写的函数,实现不同功能。

了解多态与虚函数表之间的联系与工作原理,需要首先了解三个概念:虚函数、虚函数表、虚函数表指针

class A {
};

class B {
public:
    void func(){};
};
---------------
sizeof(a) = 1;
sizeof(b) = 1;

上述创建的对象A aB b大小皆为 1,其中普通函数不占用类对象的内存空间。

class C {
public:
    virtual void func(){};
    // void *vptr; 虚函数表指针(virtual table pointer)
};
---------------
sizeof(a) = 4;  // 或 = 8

当类成员包含虚函数时,大小为 4或8(分32/64位系统),这是由于创建对象时,构造函数会自动的创建一个虚函数表指针,因此类对象的大小包含了该指针的大小。

当类中存在至少一个虚函数的时候,编译器会在编译期间生成一个虚函数表(virtual table),一直伴随类保存在可执行文件中。同时在编译期间,会在构造函数中,为虚函数表指针安插赋值语句,从而能使类的对象在创建时,让 vptr 指向类的 vtbl。

例如以下类:

class D {
public:
    virtual void func(){};
    ~D(){};
    // void *vptr; 虚函数表指针(virtual table pointer)
private:
    int a_;
    int b_;
};

当创建出对象 D d 时,包含 a_、b_、vptr 三个成员变量,因此大小为 12(假设32位系统)。其中 vptr 指向类D的虚函数表 vtbl,在虚函数表中保存了指向虚函数 func、虚析构函数的指针。

当 SubD 类进行继承的时候,子类会拷贝一份父类的虚函数表,并将重写的虚函数指针地址进行替换,新增的根据声明位置一次追加到表中。

首先需要明确,没有虚函数,绝不可能存在多态

在实现上,若一个函数的调用,首先①通过vptr根据vtbl找到虚函数表②查询执行函数的入口地址③并执行该函数,则是多态。可以根据汇编代码,来比较一下函数的调用方式。

如何确认是否存在多态调用?

  1. 首先程序中即存在父类,也存在子类。父类包含虚函数,子类也需要重写该虚函数
  2. 父类指针指向子类对象
  3. 通过父类指针或引用,调用子类重写的虚函数
class C {
public:
    virtual void func(){};
};

int main()
{
    C *c1 = new C();
    c1->func();

    C c2;
    c2.func();

    C *c3 = &c2;
    c3->func();
}

上述用例中,由于c2是通过对象直接调用,在编译期间就明确了对象类型,因此不会触发多态行为。

可以引出新的概念”虚基表“,它主要用于解决多继承中的重复基类问题,确保派生类只有一个基类的实例。

这张表在编译时生成,存储在程序的只读数据段中,它是一个全局只读的数据结构,包含了虚基类偏移量的表。因此可以在运行时确定虚基类在派生类对象中的实际地址。

虚基表通常是一个指针,紧紧跟在虚函数表指针之后。

动态分配内存可以使用 malloc,他是 C 库中的函数,使用该方法时,会通过两种方式向操作系统申请堆内存:

  1. 当分配内存小于 128 KB 时通过 brk() 系统调用从堆分配内存(堆顶指针向高地址移动,获取内存空间),若使用 free 释放并不会将内存还给操作系统,而是缓存在 malloc 的内存池中,待下次使用
  2. 当内存大于 128 KB 时,则通过 mmap() 系统调用,在匿名内存区域,即独立于堆的区域(通常在堆与栈之间),使用私有匿名映射的方式获取内存,使用 free 释放时,内存能真正的释放归还给操作系统

匿名内存是指,与文件系统中的任何文件没有关联,用于存储进程的动态数据(包含堆、栈)的内存

匿名内存区域是内存布局中的一个区域,和文件映射区相连,通常位于堆与栈区域之间

可观察 /proc//smaps,内存地址会标注为 [heap] 或 [stack]

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

下面定义两组嵌套函数,并期望参数为左值/右值引用的函数 out 调用参数为左值/右值引用的函数 inner,有:

    void inner(const string &s)
    {
        cout << "l val:" << s << endl;
    }

    void inner(string &&s)
    {
        cout << "r val:" << s << endl;
    }

    void out(const string &s)
    {
        inner(s);
//        inner(std::forward<const string&>(s));
//        inner(static_cast<const string&>(s));
    }

    void out(string &&s)
    {
        inner(s);
//        inner(std::forward<string&&>(s));
//        inner(static_cast<string&&>(s));
    }
    
int main()
{
    out(string("Halo r")); // r val
    string s = "Halo l";   // l val
    out(s);
}
-------------
l val: r val
l val: l val

案例中使用了分别传入了左值 s 和右值,观察输出却发现即使传入了右值,仍然调用了参数为左值的函数。该现象可以通过注释中的 std::forward 以及 static_cast 来解决。

不足的是,当参数过多时,需要额外的判断参数类型,增加开发人员的工作量,因此通常结合函数模板使用:

    template<class T>
    void out(T&& v) {
        inner(std::forward<T>(v));
    }
------
入参为右值时,T 被推导为 string
入参为左值时,T 被推导为 string &,模板参数推导为实参类型的引用

这里的模板类型参数的右值引用形式 T&&,被称为万能引用,它既能绑定左值也能绑定右值。

将传入左值的代码展开,能得到结果为:

void out<string&>(string& && v) {
    inner(std::forward<string&>(v));
}
string& && forward<string&>(string& t) noexcept {
    return static_cast<string& &&>(t);
}
----------------折叠后---------------
void out<string&>(string& v) {
    inner(std::forward<string&>(v));
}
string& forward<string&>(string& t) noexcept {
    return static_cast<string&>(t);
}

由于string& &&并不被允许,因此需要借助引用折叠规则:当实际类型的参数为引用时,它和模板代码中出现的引用组合在一起,会出现引用的引用,则需要转换为单一的引用。原则是,除了右值引用的右值引用会被折叠为右值引用,其他的组合中只要存在左值引用,将被折叠成左值引用。

使用智能指针 shared_ptr 可以共享所有权,让多个 shared_ptr 同时指向并拥有一个对象。那么只有在最后一个拥有该对象的 shared_ptr 被销毁或释放对象所有权时,对象才会被自动删除。

shared_ptr 的行为通过引用计数实现,能简单理解为创建时调用构造函数使计数加 1,析构时则计数减 1,同时利用原子操作保证引用计数更新的线程安全性

需注意 shared_ptr 不能保证多线程访问和修改同一个对象的线程安全,需要使用互斥锁或其他同步机制来保证

由于 shared_ptr 的引用归 0 才释放的特性,那么两个或多个对象互相引用,则会导致引用计数永远无法降为0,从而导致循环引用,无法释放内存。例如:

class B;

class A {
public:
    std::shared_ptr<B> b_ptr;
    // std::weak_ptr<B> b_ptr; 使用 weak_ptr 避免循环引用
};

class B {
public:
    std::shared_ptr<A> a_ptr;
};

int main() {
    std::shared_ptr<A> a = std::make_shared<A>();
    std::shared_ptr<B> b = std::make_shared<B>();

    a->b_ptr = b;
    b->a_ptr = a;
	
    // 当成员变量的指针类型都为 shared_ptr 时, a 和 b 的引用计数都为 2,无法自动释放
    
    // 当类 A 使用 weak_ptr 后,b 的引用计数为 1(只有 main 中的 b 指向它)
    // a 的引用计数为 2 (有 main 中的 a 指向它,b->a_ptr 指向它)
    // 当 main 函数结束时,a 和 b 的引用计数都会降到零,对象被销毁
    return 0;
}

可以利用 weak_ptr 来解决该问题。

使用弱引用 weak_ptr 时仍需注意,是否会引起悬挂指针的问题:如多线程环境中,没有正确检查对象是否仍然存在,对象被其他线程释放。可利用以下方式使用:

std::shared_ptr<B> b_from_a = a->b_ptr.lock();
if (b_from_a) {
    std::cout << "b is still valid" << std::endl;
    // 在这个范围内,b_from_a 保持对 b 的引用,b 不会被释放
    // lock() 函数会尝试获取一个对象,获取成功引用计数会增加,因此访问是安全的
}

最后,使用智能指针需要注意通过 get() 获取原始指针导致的安全问题,由于使用原始指针绕过了智能指针的自动内存管理机制,也容易引发竞态条件和悬挂指针问题。