Effictive C++ 第一章知识总结

发布于:2025-08-17 ⋅ 阅读:(13) ⋅ 点赞:(0)

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

  1. C++ 在设计上仍以 C 语言为基础,因此继承了 C 语言的诸多核心特性。例如,用于将多条语句组合为单一逻辑单元的区块(复合语句)(由一对花括号包裹)、作为执行具体操作基本单位且以分号结尾的语句、在编译前对源代码进行预处理的预处理器、语言原生支持的内置数据类型,以及用于存储数据序列的数组、用于存储变量内存地址并支持直接内存操作的指针等,均源自 C 语言。
  2. 在此基础上,C++ 拓展出了独有的核心特性,形成了不同的功能维度:
    其一为面向对象的 C++(Object-Oriented C++),这是 C++ 区别于 C 的关键特性。它引入了类(class)的概念,并通过构造函数完成对象初始化、析构函数释放资源,同时实现了面向对象的三大核心机制 ——封装(隐藏对象内部实现)、继承(复用已有类的特性)、多态(同一接口适配不同实现),而虚函数(virtual function) 正是实现多态的核心技术。
  3. 其二为泛型编程的 C++(Template C++),这是 C++ 实现代码复用与类型无关编程的重要部分,通过模板(Template)机制,允许开发者编写不依赖具体数据类型的通用代码,大幅提升代码的灵活性与复用性。
  4. 其三是标准模板库(STL),它是基于模板机制构建的成熟程序库。STL 内部对容器(如 vector、map 等数据结构)、迭代器(连接容器与算法的 “桥梁”)、算法(如排序、查找等通用操作)、函数对象(可像函数一样调用的对象)的设计进行了高度协同与规约,各组件间配合紧密,为 C++ 开发提供了高效、通用的基础工具集。

为何对内置类型(c-like)而言,pass-by-value通常比pass-by-reference高效?
        pass-by-value是值传递:函数调用时,会复制实参的值给形参。函数内部操作的是这个副本,对形参的修改不会影响外部实参。
        pass-by-reference是引用传递:函数调用时,传递的是实参的内存地址。函数内部通过地址直接操作外部实参,不产生副本。
        内置类型(int、float、char、double等)的特点是内存极小。对于内置类型,值传递更高效的原因是:复制值的成本<间接访问成本。在值传递的时候仅需复制1-8字节的数据。而引用传递虽然不需要复制数据但存在隐藏开销,首先是需要传递地址即指针本身、然后是内部访问数据时需要间接读取内存即解引用的时候。

为何面向对象及模板场景下,常引用传递会成为更优方案?

        在C的过程式编程部分,主要操作内置类型和简单结构体,数据的直接操作。此时的内置类型体积小,值传递复制成本低、效率高。而在面向对象编程,核心操作的是用户自定义对象,它体积可能很大因为包含多个成员、构造/析构开销、可能还有动态分配内存。

        用值传递的话会触发对象的拷贝构造。而拷贝构造会造成额外的性能开销,如果是深拷贝的话还可能导致资源重复释放,否则易出错。而常引用传递避免了拷贝构造、直接操作原对象的别名。而const又保证了不会修改原对象。此外仅传递了一个指针大小的引用,成本极低,且跳过了构造/析构的额外开销。

        在模板编程中,常引用传递的优势进一步放大。模板参数T可能是内置类型、也可能是大对象,若用值传递,对大对象而言会有巨大的开销。还避免了隐式拷贝。

为什么STL场景下,C风格的值传递又变得适用?

        STL的迭代器、函数对象,虽然用的是类模板,但设计模板是模拟指针的轻量高效。迭代器的本质是智能指针的简化版,通常仅包含一个原始指针,体积和指针相当。而函数对象往往是无状态或轻状态,拷贝它们的成本和拷贝指针,内置类型几乎一样。

        传递成本=拷贝成本+访问成本。对迭代器/函数对象,它们的体积和内置类型、指针相当。用值传递时,无需解引用,直接操作副本即可。

本质:规则因场景而变

        C++的参数传递没有绝对的最优解,核心是匹配类型的设计意图:大对象/复杂对象->用引用传递避免拷贝拷贝。轻量如指针/内置类型的对象->用值传递更高效。

条款二:尽量以const、enum、inline替换#define

#define ASPECT_RATIO 1.653

        #define它本质上是预处理指令。在编译前让预处理器把代码中所有ASPECT_RATIO替换为1.653。在这个过程中ASPECT_RATIO对编译器不可见,因为预处理器将它替换为数值。编译后,目标码中每处使用的地方都会存储一份1.653的二进制数据,造成冗余。

        它会导致两个问题:编译报错不直观。如果是ASPECT_RATIO的地方出错,编译器看到的是1.653。所以报错信息里只会提1.653而不是ASPECT_RATIO。调试器无法识别。若调试的时候想查看ASPECT_RATIO的值,但调试器的符号表里面没记录这个名字,只能看到1.653。如果它是定义在别人写的文件里面,就会完全搞不清楚1.653是代表什么含义。

const double AspectRatio = 1.653

        const常量会被编译器识别。进入符号表,调试、报错时能直观定位常量。编译器会将1.653作为常量值存入目标码的常量区,所有引用它的地方都会指向同一份的数据,节省存储空间。

const char * const authorName = "ABCD";

        如果用const定义指向字符串的常量指针,需要const修饰两次(左侧const指针所指的内容是常量,右侧const指针本身是常量)。这样定义是因为常量通常放在头文件,需要确保指针和指向的内容都不会被意外改变,避免跨文件引用时出现问题。

如何用模板内敛函数替代宏,避免宏的副作用?

#define CALL_WITH_MAX(a,b) f((a)>(b)?(a):(b)) 

int a=5,b=0;
CALL_WITH_MAX(++a,b);
CALL_WITH_MAX(++a,b+10);

        宏的本质是文本替换,宏参数会被多次替换执行。宏不检查参数类型,传入非法类型不会报错。

template<typename T>
inline void calWithMax(const T&a,const T& b){
    f(a?b?a:b);
}

        用目标那内敛函数替代宏,模板可以适配不同类型,无需手动写多个重载。inline让编译器直接展开函数体,避免函数调用的栈。

        普通函数调用时候,程序 跳转到函数定义地方执行,执行完后跳回来。而内敛函数调用让编译器直接把函数体代码粘贴到调用处,省去跳转的开销。

条款三:尽可能使用const

const Widget* pw;
Widget const * pw;
// 这两者是等效的

        指针与const的关联。const在指针星号不同位置,分别表示被指物常量,指针自身常量,两者均为常量。

vector<int> vec = {1,2,3};
// 1、const 迭代器
vector<int>::iterator const iter = vec.begin();
*iter = 10;	// 允许
++iter;		// 报错
// 2、const_iterator
vector<int>::const_iterator cIter = vec.begin();
*cIter = 10;	// 报错
++cIter;		// 允许

        STL迭代器与const。STL迭代器的设计模仿指针行为。

        const 迭代器(vector<int>::iterator const):类似于 T* const,迭代器本身是常量,不能改变其指向元素的位置,但可以通过解引用*iter修改所指元素的指。

        const_iterator(vector<int>::const_iterator):类似于const T*,迭代器所指向的元素是常量,不能通过解引用来修改元素的值,但迭代器本身可以移动。通常用于只读遍历容器,避免误改元素内容。

class Widget{
public:
    int value;
    void setValue(int c) {value = v;}
    void print() const {cout<<value;}
}
const Widget w;	// w是const对象,value不能被修改

        返回值const约束。让函数返回const值,核心作用是防止不合法的赋值操作。

        参数的const修饰。确保函数内部不会通过指针/引用修改外部传入的对象,避免意外篡改调用者的数据。

        成员函数的const修饰。在类成员函数后加const,编译器会检查函数体内是否有修改成员的操作,强制保证只读行为。此外支持const对象调用,const对象只能调用const成员函数,否则肯通过函数修改对象状态,破坏const语义。

operator[] 重载实现差异化访问

class TextBlock{
public:
    const char& operator[] (size_t position) const{
        return text[position];
    }

    const& operator[](size_t position){
        return text[position];
    }
private:
    string text;
}

类重载operator[]时候,依据代用对象是否为const,会提供两种行为。

        非const对象。调用返回char& 版本,支持读+写操作

        const对象。调用返回const char& 版本,仅支持读操作。

        const修饰的operator[]:函数后加const,表示这是const成员函数,只能被const对象调用。返回const char& 确保调用者无法通过返回值修改text的内容。

        非const版本的operator[]:无const修饰,供非const对象调用。返回的char& 允许赋值修改。

        物理性常量(bitwise constness):编译器只看是否改变了成员变量。只检查指针本身是否被修改(pText=new char[10];会被禁止),但允许通过指针修改它指向的内容(PText[0]='x';不会被编译器拦截)。

        逻辑性常量(logical constness):对象状态看似没改变,但内部指针指向的内容被修改。调用(ctb[0]='x';),ctb是const对象,但时间修改了pText指向的字符串,破坏了const语义。

class CTextBlock{
public:
    char& operator[](size_t position) const{
        return pText[position];
    }
private:
    char* pText;
}

        从物理性常量来看:operator[]没修改成员变量,所以编译器会认为它是合法的。从逻辑性常量来看:外部调用者可以通过返回char&修改pText指向的字符(ctb[0]='x';),这相当于修改了对象的逻辑状态。

class CTextBlock{
public:
    size_t length() const;
private:
    char* pText;
    size_t textLength;	// 缓存,文本长度
    bool lengthIsValid;	// 长度是否有效
}

size_t CTextBlock::length() const{
    if(!lengthIsValid){
        textLength = strlen(pText);		// 错误 修改了textLength
        lengthIsValid = true;			// 错误 修改lengthIsValid
    }
    return textLength;
}

        textLengthlengthIsValid是缓存数据,修改它们并不会改变CTextBlock对外部的只读表现。(客户端用length()时,只关心返回值。并关心内部缓存是否更新)。在函数内部来看,修改textLengthlengthIsValid编译器会报错,阻止这类合理的优化。

class CTextBlock{
public:
    size_t length() const;
private:
    char* pText;
    // mutable标记 允许在 const 成员函数中修改
    mutable size_t textLength;	// 缓存,文本长度
    mutable bool lengthIsValid;	// 长度是否有效
}

size_t CTextBlock::length() const{
    if(!lengthIsValid){
        textLength = strlen(pText);		// 合法 修改了textLength
        lengthIsValid = true;			// 合法 修改lengthIsValid
    }
    return textLength;
}

textLengthlengthIsValidmutable修饰后,编译器不再将它们的修改视为违反const语义。这样即满足了物理性常量的语法要求,又实现了缓存修改对客户端透明。

const语义的平衡

        物理性常量:编译器的底线规则,确保对象的物理bits(非mutable成员)不被修改,是语法层面的强制约束。

        逻辑性常量:开发者的逻辑需求,允许const成员函数修改对象的某些bits(如缓存),但必须保证客户端观测不到这些修改,是更高层面的设计意图。

        mutable的角色:作为例外通道,释放非静态成员的物理性约束,让逻辑性常量可以实现,解决语法合法但逻辑矛盾的问题。

const与non-const成员函数的代码重复问题

class TextBlock{
public:
    const char& operator[](size_t position) const{
        // 边界检验
        // 标记访问
        // 校验完整性等逻辑
        return text[position];
    }

    char& operator[[](size_t position){
        // 边界检验
        // 标记访问
        // 校验完整性等逻辑
        return text[position];
    }
private:
    string text;
};

        const与non-const都要执行边界检验、标记访问信息、校验数据完整性等相同逻辑。但是分别在两个函数中重复实现。如果后续要修改这些检验,需要同时修改这两个版本的函数,麻烦易出错。

class TextBlock{
public:
    const char& operator[](size_t position) const{
        // 边界检验
        // 标记访问
        // 校验完整性等逻辑
        return text[position];
    }

    char& operator[[](size_t position){
        return const_cast<char&>(
            static_cast<const TextBlock*>(this)->operator[](position)
        );
    }
private:
    string text;
};

        在C++中,const_cast和static_cast都是类型转化操作符,用于在不同类型之间进行转换,但它们的功能,使用场景,转换限制等方面存在区别。

        const_cast:主要用于移除或添加对象的常量性(const或volatile限定符)。比如将const类型转换为非const类型,或者将非const类型转换为const类型。它是唯一能移除const限定的标准类型转换操作符。只能对带有 constvolatile 限定符的类型进行转换,并且转换前后的类型除了 constvolatile 限定符不同外,其他部分必须相同。例如不能将 const int 直接转换为 double,只能转换为 int 。如果尝试进行不满足条件的转换,编译器会报错。

        static_cast:用于进行较为安全的类型转换,比如基本数据类型之间的转换、又继承关系的类之间的向上转型(派生类转基类)和向下转型(基类转派生类)。

        static_cast<const TextBlock*>(this):将this指针从TextBlock*强制转换为const TextBlock*。this指针在non-const成员函数中是TextBlock*类型,默认会调用non-const版本的operator[]。为了强调用const版本的operator[],需要先将this转化为const指针,让编译器匹配const版本的函数。

        ->operator[](position):通过转换后的const TextBlock*指针,调用const版本的operator[]。得到的返回值是const char&类型的返回值。

        const_cast<char&>(...):将const char& 返回值转换为char&。

        这样就彻底消除重复,non-const版本只需要一行代码,完全复原const版本的逻辑,修改时候只需要维护const版本。

两次转型的本质:解决递归调用问题

        如果non-const operator[]直接不加转型调用operator[]会发生什么?

char& operator[](size_t position){
    return operator[](position);	// 错误 递归调用自己
}

        因为this是TextBlcok*的非const指针,调用operator[](position)会匹配non-const版本,导致无限递归(自己调用自己,直到栈溢出)。所以为了让non-const operator[] 调用const版本,必须先将this指针转化为const TextBlcok*。另外const_cast是唯一能移除connst现代的合法方式。

反方向调用的风险:const调用non-const

        假设让const调用non-const调用non-const。

char& operator[[](size_t position) const{
        return const_cast<const TextBlock*>(this)->operator[](position));
    }

        风险:const成员函数承认不修改对象的逻辑状态,但non-const版本可能修改对象。

条款四:确保对象被使用前已先被初始化

C与STL初始化规则的差异

        C++可分为C兼容部分和STL等扩展部分,不同部分初始化规则不同。

int arr[5];

        不保证自动初始化,arr中的元素可能是随机值,需要手动初始化。

vector<int> vec(5);

        保证自动初始化,vec中的5个元素都会被默认初始化为0。无论对象是来自C++的兼容部分还是STL,最佳实践是使用前手动初始化。

int x=0;
const char* text="Hello";
double d;	

        内置类型无构造函数,需手动初始化。自定义类型(类、STL容器等),自定义类型依赖构造函数初始化,需在构造函数中确保所有成员被初始化。

构造函数中的初始化、赋值陷阱

class ABEntry{
public:
    ABEntry(const string& name,const string& address,const list<PhoneNumber>& phones){
        theName = name;	// 赋值,非初始化
        thePhone = phones;
        numtime = 0;
    }
private:
    string theName;
    list<PhoneNumber> thePhones;
    int numtimne;
}

        theName、thePhones、numtimne先被默认构造(调用无参构造函数),在被赋值(调用拷贝赋值运算符)。默认构造+赋值,增加额外允许时开销。

class ABEntry{
public:
    ABEntry(const string& name,const string& address,const list<PhoneNumber>& phones)
    :theName(name),
     thePhone(phones),
     numtime(0){
    }
private:
    string theName;
    list<PhoneNumber> thePhones;
    int numtimne;
};

        应使用初始化列表直接初始化成员,而非先默认构造在赋值。初始化:成员在构造函数初始化列表中直接构造,只调用一次构造函数。赋值:成员先默认构造,在调用拷贝赋值运算符,多一次不必要的操作。

class ABEntry{
public:
    ABEntry(const string& name,const string& address,const list<PhoneNumber>& phones)
    :theName(),
     thePhone(),
     numtime(){
    }
private:
    string theName;
    list<PhoneNumber> thePhones;
    int numtimne;
};

        即使成员变量需要默认构造(无参构造),也建议用初始化列表显示声明。C++中,构造函数体执行前,成员变量的初始化已通过默认构造悄悄发生。如果在构造函数体中赋值,会导致默认构造+赋值的冗余开销。C++规定:const成员和引用成员必须在初始化列表中初始化,无法在构造函数体中赋值。原因,const成员的值不能改变,引用必须绑定到有效对象,因此必须在构造函数执行前(初始化阶段)完成初始化。

class Widget{
public:
    Widget(int val) :ref(val),data(10){}	// 必须用初始化列表
private:
    int& ref;	// 引用成员,必须初始化
    const int data;	// const成员,必须初始化
}

如果遗漏初始化列表,编译器会报错。

class ABEntry{
public:
    ABEntry() {}	//	 未初始化
private:
    int numTime;
}

内置类型如果在初始化列表中被遗漏,不会自动初始化,值为随机值。

class ABEntry{
public:
    ABEntry(...)
    :numtime(),		// 初始化顺序 3
     thePhone(),	// 2
     theName(){		// 1
    }
private:
    string theName;		// 声明顺序 	1
    list<PhoneNumber> thePhones;	// 2
    int numtimne;	// 3
};

成员的初始化顺序由其在类中声明的顺序决定,与初始化序列无关。

C++中跨编译单元的函数外静态对象初始化次序问题

        函数内的静态对象:作用域局部,生命周期从首次调用到程序结束。函数外的静态对象:全局对象、命名空间作用域的对象、类的static成员、文件作用域中的static对象。所有static对象的生命周期从构造到程序结束。编译单元:产出单一目标文件(.obj)的源码集合,通常是单个.cpp文件+其#include的头文件。C++编译以编译单元为单位,不同编译单元的代码独立编译,最后链接为可执行程序。

// filesystem.cpp
class FileSystem(/*...*/);
extern FileSystem tfs;	//声明为extren,供其他编译单元使用
FileSystem tfs;		// 定义函数外静态对象

// directory.cpp
class Directory{
public:
    Directory(){
        tfs.numDisks();		// 依赖上一编译单元的tfs对象
    }
};
Directory dir;	// 定义局外静态变量对象dir,构造函数依赖tfs

C++中并没有定义不同编译单元的局外静态变量的初始化顺序。可能出现两种情况:1、tfs先初始化,dir后初始化,构造时tfs可用,程序正常。2、dir先初始化,tfs后初始化,构造时tfs为构造(空值或随机值),调用会触发未定义行为。

// filesystem.cpp
class FileSystem(/*...*/);
FileSystem& tfs(){
    static FileSystem instance;	//	局内静态对象,首次调用初始化
    return instance;
}

// directory.cpp
class Directory{
public:
    Directory(){
        tfs().numDisks();		// 调用tfs().触发instance初始化
    }
};

Directory dir(){
    static Directory instance;	// 局部静态对象,首次调用时初始化
    return instance;
}

        tfs()和dir()函数内的局内静态对象,其初始化由函数调用触发。当dir()调用tfs()时,tfs()的局内静态对象会先于dir()的局内静态对象初始化,保证依赖顺序。

        为避免对象初始化的风险,要手动初始化内置模型优先用成员初始化列表用局内静态函数解决跨编译单元的顺序问题。