第7章 类

第七章 类

7.1 第一抽象函数类

7.1.2 定义改进Sales_data类

7.1.2.1 this指针的本质是 MyClass* const

  • 如果想要指向const T 就要加上 myclass() const

  • 如果想要修改 就要加上mutable

7.1.2.2 编译器分两步处理类

  1. 首先成员函数声明

  2. 成员函数体

  3. 所有无需在意顺序

7.1.2.3 return \*this; – 实现链式调用的设计模式

这部分是 C++ 中一个非常实用且优雅的编程范式。

1. 设计哲学:模仿内置运算符

  • 目标:当我们自定义一个行为类似 +=-== 的成员函数时,应该让它的行为也和内置版本保持一致。

  • 内置运算符的行为:内置的赋值类运算符(如 a += b)会返回其左侧运算对象本身,并且返回的是一个左值 (lvalue),可以被继续操作。例如 (a += b) = c; 是合法的。

2. 实现方式:返回引用 ClassName&

  • 如何返回左值:在 C++ 中,返回一个引用 (&) 就是返回对象本身(的别名),它是一个左值。

  • 返回类型:将成员函数的返回类型声明为 ClassName&

  • 返回语句:在函数末尾使用return *this;。

    • this 是指向当前对象的指针。

    • *this 是对该指针解引用,得到的就是当前对象本身

    • 因为函数返回类型是引用,所以编译器不会创建对象的副本,而是直接返回对象自身。

3. 带来的好处:链式调用

  • 效果:由于函数返回了对象自身的引用,可以立刻在该返回值上继续调用该类的其他成员函数。

  • 示例myObject.combine(obj1).combine(obj2).combine(obj3);

  • 优点:代码更紧凑、可读性更高,符合函数式编程的流式接口风格。

7.1.3 非成员辅助函数的力量

这部分笔记的核心思想是:一个优秀类的接口,并不仅仅由其成员函数构成。

1. 核心原则:分离“概念接口”与“类的成员”

  • 定义:许多函数(如 add, read, print)在概念上是类接口的一部分,因为它们是用户与类交互的关键操作。但它们在技术上不必、甚至不应该是类的成员函数

  • 目标:将这些函数实现为非成员函数 (Non-Member Functions)


2. 为什么要使用非成员函数?(两大支柱)

这是理解此设计模式的关键

  • A. 为了对称性 (Symmetry) – 最重要的原因

    • 场景1:流操作符 (<<, >>)

      • 代码形式:std::cout << my_object;

      • 根本原因:C++ 的成员函数调用形式是 对象.函数(),左侧必须是类的对象。在上面的例子中,<< 操作符的左侧是 std::coutostream 类型),而不是我们的 my_object

      • 结论operator<< 绝对不能是类的成员函数,只能是接受 ostream&my_object 为参数的非成员函数。

    • 场景2:算术操作符 (+, - 等)

      • 代码形式:result = my_object + 5; vs result = 5 + my_object;

      • 成员函数的局限:如果 operator+ 是成员函数,只能实现 my_object.operator+(5),无法实现 5.operator+(my_object)(因为 5 是内置类型,没有成员函数)。

      • 非成员函数的优势:一个非成员函数 operator+(lhs, rhs) 可以平等地处理两种情况,完美实现对称性。

  • B. 为了更强的封装 (Encapsulation)

    • 最小权限原则:非成员函数默认无法访问类的 privateprotected 成员,这限制了能直接修改对象内部状态的代码范围。

    • 显式授权 (friend):如果一个非成员函数确实需要访问私有成员(如 readprint),必须在类定义中将其显式声明为友元 (friend)。这使得“特殊权限”的授予变得清晰、可追溯,是一种更深思熟虑的封装破坏,而不是无意的暴露。


3. 如何组织代码?(物理布局规则)

这是将设计思想落地为代码实践的指导。

  • 规则:将这些非成员辅助函数的声明 (Declaration),与类的声明放在同一个头文件 (.h) 中。

  • 位置:通常将这些函数的声明紧跟在 class MyClass { ... }; 代码块之后。

  • 定义 (Definition):函数的具体实现,则像其他函数一样,放在对应的源文件 (.cpp) 中。


4. 最终目的与好处

  • 提升用户体验:提供“一站式”服务

    • 用户只需 #include "MyClass.h" 这一个文件,就能获得使用该类所需的全部接口(包括类本身和所有相关的辅助函数)。

    • 避免了用户为了寻找某个功能而去包含多个头文件的麻烦。

  • 加强逻辑聚合 (Logical Cohesion)

    • 在物理文件层面,将一个组件的完整接口聚合在一起。这使得代码库的结构更清晰,也更易于维护和理解。

7.1.4 构造函数

  1. 构造函数不能声明为const

  2. 中文深度剖析:为何必须警惕“免费午餐”——合成默认构造函数

    这段文字的核心思想是:编译器提供的“合成默认构造函数”虽然方便,但它是一份有着严格使用条件的“免费午餐”。在很多常见情况下,这份午餐不仅不存在,甚至可能“有毒”。我们必须理解其三大局限性,才能编写出健壮、可预测的类。

    第一大原因:一旦插手,编译器便“撒手不管”(The “All or Nothing” Rule)

    原文引用:“编译器只有在发现类不包含任何构造函数的情况下才会替我们生成一个默认的构造函数。一旦我们定义了一些其他的构造函数,那么除非我们再定义一个默认的构造函数,否则类将没有默认构造函数。”

    深度剖析:

    这可以理解为 C++ 的“初始化控制权移交”规则。

    • 规则本身:只要你为类写了任何一个构造函数(无论带不带参数),编译器就会认为:“哦,这位程序员已经开始亲自掌管这个类的初始化过程了。那么,我就不再自作主张地提供任何默认的初始化方案了。”

    • 背后的逻辑:这个规则并非为了刁难程序员,而是为了防止意外。如果一个类需要一个特殊的、带参数的构造方式(比如 MyClass(int, double)),那么它的“默认”状态很可能也需要特殊处理,甚至可能根本就不应该存在一个“默认”状态。编译器强迫你明确地思考这个问题,而不是留下一个可能不符合你设计意图的、自动生成的默认构造函数。

    • 具体后果

      C++

      class MyWidget {
      public:
          // 一旦我们定义了这个构造函数...
          MyWidget(int value) : data(value) {}
      private:
          int data;
      };
      
      int main() {
          MyWidget w1(10); // 正确,调用我们定义的构造函数
          MyWidget w2;     // 编译错误!编译器不再提供默认构造函数
      }
    • 现代解决方案 (= default):如果你确实需要一个默认构造函数,并且希望它的行为和编译器合成的完全一样,C++11 提供了优雅的解决方案:

      C++

      class MyWidget {
      public:
          MyWidget() = default; // 明确地向编译器“要回”默认版本
          MyWidget(int value) : data(value) {}
      private:
          int data;
      };

    第二大原因:合成版本可能“玩忽职守”,留下“垃圾值”

    原文引用:“如果定义在块中的内置类型或复合类型(比如数组和指针)的对象被默认初始化…则它们的值将是未定义的…含有内置类型或复合类型成员的类应该…定义一个自己的默认构造函数。”

    深度剖析:

    这是 C++ 从 C 语言继承而来的“为性能负责”的特性,但也是一个巨大的陷阱。

    • 合成构造函数的行为:它在初始化成员时,对于类类型成员,会调用该成员的默认构造函数;但对于内置类型(int, double…)和复合类型(指针、数组),它采取“默认初始化”策略,即什么也不做

    • 后果:未定义的行为:这意味着这些成员变量的值将是内存中遗留的、完全随机的“垃圾值”。使用一个未初始化的指针可能会导致程序崩溃,使用一个未初始化的 int 可能会导致灾难性的计算错误。

      C++

      class BadConnection {
      public:
          // 没有构造函数,编译器会合成一个默认的
          void connect() {
              // port 和 retry_count 的值是完全随机的!
              // conn_ptr 是一个野指针,解引用它会导致程序崩溃!
              std::cout << "Connecting to port " << port << std::endl;
              *conn_ptr = 1; 
          }
      private:
          int port;          // 未定义的垃圾值
          int retry_count;   // 未定义的垃圾值
          int* conn_ptr;     // 指向未知地址的野指针
      };
    • 解决方案

      1. 类内初始值 (C++11 及以后,推荐):这是最现代、最清晰的做法。

        C++

        class GoodConnection {
        private:
            int port = 8080;
            int retry_count = 3;
            int* conn_ptr = nullptr; // 明确初始化为安全状态
        };
      2. 自定义默认构造函数 (传统方式):如果编译器不支持类内初始值,或有更复杂的逻辑,则必须自己写。

        C++

        class GoodConnection_Legacy {
        public:
            GoodConnection_Legacy() : port(8080), retry_count(3), conn_ptr(nullptr) {}
        private:
            int port;
            int retry_count;
            int* conn_ptr;
        };

    第三大原因:编译器有时“无能为力”,无法合成

    原文引用:“如果类中包含一个其他类类型的成员且这个成员的类型没有默认构造函数,那么编译器将无法初始化该成员。”

    深度剖析:

    这揭示了类构造的依赖链。一个类要能被默认构造,它的所有成员也必须能被默认构造。

    • 逻辑链条

      1. Container 类包含一个 Member 类的对象作为成员。

      2. 当编译器尝试为 Container 合成默认构造函数时,它必须知道如何构造那个 Member 对象。

      3. 它会尝试调用 Member 类的默认构造函数

      4. 如果 Member没有默认构造函数(比如它所有的构造函数都需要参数),编译器的尝试就会失败。

    • 后果:编译失败:编译器会直接放弃,并报告一个错误,指出它无法为 Container 生成默认构造函数,因为它不知道如何处理其中的 Member 成员。

      C++

      class DatabaseConnector {
      public:
          // 这个类没有默认构造函数,必须提供一个连接字符串
          DatabaseConnector(const std::string& connection_string) { /* ... */ }
      };
      
      class AppService {
      private:
          // AppService 依赖于 DatabaseConnector
          DatabaseConnector db_connector; 
      };
      
      int main() {
          // 编译错误!
          // 编译器想为 AppService 合成默认构造函数,
          // 但它不知道该如何创建 db_connector,因为 DatabaseConnector 没有默认构造函数。
          AppService my_app; 
      }
    • 解决方案AppService 必须自定义构造函数,并在其构造函数初始值列表中明确地、手动地初始化 db_connector 成员。

      C++

      class AppService {
      public:
          // 必须提供一个构造函数,并告诉它如何创建 db_connector
          AppService(const std::string& db_string) 
              : db_connector(db_string) // 明确调用 DatabaseConnector 的构造函数
          {}
      private:
          DatabaseConnector db_connector;
      };

7.2 访问控制与封装

7.2.1 友元

关键点

  1. **友元声明的位置**
     - 可以放在`public`、`private`或`protected`区域,不影响效果(因为友元不受访问控制约束)。
     - 通常放在类定义的**开头或结尾**,便于阅读。
  2. **友元的作用**
     - 允许**特定函数或类**访问当前类的`private`成员。
     - 友元关系**不具有传递性**(即`A`是`B`的友元,`B`是`C`的友元,不意味着`A`是`C`的友元)。
  3. **友元的优缺点**
     - **优点**:提供灵活性,允许某些外部函数直接操作私有数据(如`read`、`print`)。
     - **缺点**:破坏封装性,应谨慎使用(通常优先用公有成员函数替代)。

为什么readprintadd适合作为友元?

  • 它们需要直接访问Sales_dataprivate数据(如bookNounits_sold)。

  • 它们属于类的核心接口,但逻辑上不适合作为成员函数(例如add返回一个新对象,而非修改当前对象)。


1. 友元声明的本质

  • 友元声明的作用:仅授予函数/类访问当前类private成员的权限,并不等同于函数声明本身。

    class Sales_data {
        friend void foo(Sales_data &); // 仅声明foo是友元,但未真正引入foo的函数声明
    };
    • 如果用户代码直接调用foo(),编译器可能因找不到函数声明而报错(除非编译器扩展允许隐式声明)。

2. 必须提供独立的函数声明

为了让友元函数能被用户调用,需在类外额外声明该函数(通常与类定义放在同一头文件中):

// sales_data.h
class Sales_data {
    friend Sales_data add(const Sales_data&, const Sales_data&); // 友元声明(权限)
    // ... 其他成员
};

// 独立的函数声明(使add对用户可见)
Sales_data add(const Sales_data&, const Sales_data&);

3. 头文件的组织建议

将友元函数的声明放在类定义之后同一命名空间中,确保用户#include时能同时看到类和友元声明:

// sales_data.h
class Sales_data {
    friend void helper(const Sales_data&); // 友元声明
public:
    // ... 公有接口
};

// 类的用户需要看到的友元函数声明
void helper(const Sales_data&);

7.3 类的其他特性

7.3.1 类成员再探

情景 是否合法 原因
类型别名在成员之后 ❌ 错误 编译器无法确定标识符是类型还是成员(如 pos 可能是变量或类型)。
类型别名在成员之前 ✅ 合法 编译器明确知道 pos 是类型。
普通成员交叉声明 ✅ 合法 函数/变量名称在类定义结束后才需解析。

什么是两阶段查找?

两阶段查找是 C++ 编译器在处理模板(尤其是类模板的成员函数)时,解析其中名称(如变量名、函数名、类型名等)的一套规则。这个过程被明确地分为两个阶段:

  1. 第一阶段:模板定义时(At Template Definition Time)

  2. 第二阶段:模板实例化时(At Template Instantiation Time)

这个机制的设计初衷是为了在模板编程这个高度灵活的领域里,尽可能早地发现代码错误,同时又能正确处理那些只有在模板被具体类型实例化后才能确定的名称。


为什么要分两个阶段?

想象一下,如果你写了一个类模板:

template<typename T>
class MyContainer {
public:
    void process() {
        // ... 一些代码 ...
        int x = 10;
        std::cout << x << std::endl; // (A)
        T::static_function();        // (B)
        obj.some_method();           // (C)
    }
private:
    T obj;
};

在这个模板中,编译器遇到了三个不同的名称:std::coutT::static_functionobj.some_method

  • 对于 std::cout,它的含义是固定的,不随 T 的变化而变化。编译器在读到这行代码时,就应该能立即检查 std::cout 是否存在,用法是否正确。

  • 对于 T::static_function,它的有效性完全取决于 T 是什么。如果 T 是一个有 static_function 静态成员函数的类,代码就正确;否则就是错误的。

  • 对于 obj.some_method,同样地,obj 的类型是 Tsome_method 是否存在也完全取决于 T

如果编译器等到 MyContainer<SomeType> 被实例化时才检查所有名称,那么像 std::cout 拼写错误(比如写成了 std::cuot)这样的简单错误也要等到实例化时才能发现,这非常低效。

因此,C++ 标准委员会决定,必须将名称的查找分阶段进行。


两阶段查找的详细过程

第一阶段:模板定义时

当编译器第一次看到模板的定义时(不是实例化),它会立即进行第一阶段的查找。

查找对象非依赖名称(Non-Dependent Names)

非依赖名称:指那些不以任何方式依赖于模板参数 T 的名称。

在上面的例子中:

  • std::cout 是非依赖名称。

  • std::endl 是非依赖名称。

  • intx 也是非依赖名称。

此阶段的工作

  1. 语法检查:检查模板代码本身是否有语法错误。

  2. 非依赖名称查找:编译器会查找所有非依赖名称。如果找不到,或者使用方式有误(例如,对一个非函数名使用函数调用括号),编译器会立即报错

示例

C++

#include <iostream>

void external_func() {
    std::cout << "External function called." << std::endl;
}

template<typename T>
class MyTemplate {
public:
    void foo() {
        // 非依赖名称,在模板定义时就会被查找
        external_func(); 
        
        // 假设这里有个拼写错误
        external_funct(); // ERROR! 编译器在此处立即报错,无需等到实例化
    }
};

int main() {
    // 即使我们没有实例化 MyTemplate,上面的错误也会被报告
    return 0;
}

这个特性非常好,因为它让模板作者可以立即修复那些与模板参数无关的 bug。

第二阶段:模板实例化时

当代码中出现模板的具体实例化时,例如 MyContainer<int>MyContainer<MyClass>,第二阶段的查找就会启动。

查找对象依赖名称(Dependent Names)

依赖名称:指那些其含义依赖于一个或多个模板参数的名称。

常见的依赖名称包括:

  • 作为模板参数的类型 T 本身。

  • 嵌套在 T 内部的类型名,例如 T::value_type

  • T 的成员变量或成员函数,例如 obj.some_method()(其中 obj 的类型是 T)。

  • 依赖于模板参数的基类中的名称。

此阶段的工作

  1. 确定模板参数:编译器知道了 T 究竟是什么具体类型(比如 int)。

  2. 依赖名称查找:编译器现在开始查找所有依赖名称。查找的上下文是实例化该模板的位置以及与模板参数 T 相关联的命名空间(这得益于“参数依赖查找”或 ADL,Argument-Dependent Lookup)。

  3. 最终代码生成:如果所有依赖名称都找到了且用法正确,编译器就会为这个特定的实例化版本生成最终的二进制代码。如果找不到,此时才会报错

示例

C++

#include <iostream>

struct Bar {
    void do_it() {
        std::cout << "Bar::do_it()" << std::endl;
    }
};

struct Foo {
    // Foo 没有 do_it() 方法
};

template<typename T>
class Processor {
public:
    void process() {
        T instance;
        // instance.do_it() 是一个依赖名称,因为它的合法性取决于 T
        instance.do_it(); 
    }
};

int main() {
    Processor<Bar> p_bar;
    p_bar.process(); // OK! T=Bar, Bar 有 do_it() 方法,第二阶段查找成功

    Processor<Foo> p_foo;
    p_foo.process(); // ERROR! T=Foo, Foo 没有 do_it() 方法,第二阶段查找失败
                     // 错误信息会指向 process() 的实例化点
}

两阶段查找带来的常见“陷阱”和解决方案

理解两阶段查找后,你就能明白为什么在写模板代码时会遇到一些看似奇怪的编译错误和语法要求。

陷阱 1:调用依赖基类的成员函数

这是一个最经典的问题。

C++

template<typename T>
class Base {
public:
    void base_func() {}
};

template<typename T>
class Derived : public Base<T> { // Derived 继承自一个依赖于 T 的基类
public:
    void derived_func() {
        base_func(); // 编译错误!
    }
};

问题分析

  • Base<T> 是一个依赖基类(dependent base class),因为它的具体类型取决于 T

  • 在第一阶段查找时,编译器看到 base_func()。这是一个非限定名称(unqualified name)。

  • 由于 base_func 可能是 Base<T> 的一个特化版本中不存在的函数,C++ 标准规定,编译器在第一阶段不应该在依赖基类中查找非限定名称

  • 因此,编译器找不到 base_func 的声明,直接报错。

解决方案:明确告诉编译器这个名称是依赖的,并且是当前对象的成员。

  1. 使用 this->

    C++

    void derived_func() {
        this->base_func(); // OK!
    }

    this 的类型是 Derived<T>*,它依赖于 T。所以 this->base_func() 就变成了一个依赖名称,查找会被推迟到第二阶段。那时编译器已经知道了 Base<T> 的完整定义,就能找到 base_func

  2. 使用 using 声明

    C++

    public:
        using Base<T>::base_func; // 将 base_func 引入 Derived 作用域
        void derived_func() {
            base_func(); // OK!
        }

陷阱 2:使用嵌套的依赖类型

C++

template<typename T>
class Container {
public:
    // 假设 T 有一个嵌套类型定义叫 value_type
    void use_type() {
        T::value_type x; // 编译错误!
    }
};

问题分析

  • 在第一阶段,编译器看到 T::value_type

  • 编译器不知道 T::value_type 是一个类型名、一个静态成员变量,还是别的什么东西。

  • C++ 编译器默认会假定它不是一个类型。因此,T::value_type x; 这句声明变量的语句在语法上就变成了 (T::value_type) * x(一个值乘以 x),这显然是错误的。

解决方案:使用 typename 关键字。

C++

void use_type() {
    typename T::value_type x; // OK!
}

typename 关键字明确地告诉编译器:T::value_type 是一个类型,请把它当作类型来处理。这样语法分析就能通过,而对该类型存在的实际检查会推迟到第二阶段。

总结

如果是依赖类型名(依赖模板参数的嵌套类型),必须在声明它的地方typename

如果是依赖基类里的成员函数/变量,需要 this->Base<T>::using

特性 第一阶段 (模板定义时) 第二阶段 (模板实例化时)
何时发生 编译器首次解析模板代码时 模板被具体类型实例化时
查找对象 非依赖名称 (不依赖模板参数) 依赖名称 (依赖模板参数)
主要目的 尽早发现与模板参数无关的语法和名称错误 解析与具体实例化类型相关的名称,生成最终代码
常见问题 无法在依赖基类中找到成员;无法识别嵌套依赖类型 实例化类型不满足模板要求(如缺少成员函数)
解决方案 使用 this->using;使用 typename 修正用于实例化的类型,使其符合模板接口

掌握两阶段查找机制,可以让你写出更健壮、更标准、可移植性更好的 C++ 模板代码,并且在遇到看似奇怪的编译错误时,能够从根源上理解问题所在。

7.3.2 令成员作为内联函数

场景 是否自动内联 示例
类内定义的成员函数 ✅ 自动 inline char get() const { return contents[cursor]; }
类外定义的成员函数 ❌ 需显式加 inline 需在定义处加 inline 关键字
场景 是否适合内联 原因
函数体简单(如1-3行) ✅ 推荐 减少调用开销
频繁调用的小函数(如get() ✅ 推荐 性能敏感场景
包含循环或递归 ❌ 避免 代码膨胀,优化效果差
虚函数 ❌ 不能 虚函数动态绑定,无法内联
  1. 虚函数的核心:动态绑定

当我们将一个派生类对象赋值给基类指针或引用时,如果没有虚函数,那么通过这个基类指针调用的将永远是基类自己的方法,无论它实际指向的是哪个派生类对象。这就是静态绑定(Static Binding),在编译期就已经确定了调用哪个函数。

虚函数打破了这一限制。

通过在基类中将函数声明为 virtual,你等于在告诉编译器:“这个函数的最终版本要到运行时才能确定。请不要在编译期就定死,而是根据指针或引用实际指向的对象的类型来决定调用哪个版本。”

简单示例:

C++

#include <iostream>

class Shape {
public:
    // 使用 virtual 关键字,开启动态绑定
    virtual void draw() const {
        std::cout << "Drawing a generic Shape." << std::endl;
    }
    virtual ~Shape() = default; // 基类的析构函数通常也应是虚函数
};

class Circle : public Shape {
public:
    // override 关键字是好习惯,它让编译器检查基类是否存在同名虚函数
    void draw() const override {
        std::cout << "Drawing a Circle." << std::endl;
    }
};

class Square : public Shape {
public:
    void draw() const override {
        std::cout << "Drawing a Square." << std::endl;
    }
};

void render(const Shape& shape) {
    shape.draw(); // 这里的调用就是动态绑定
}

int main() {
    Circle c;
    Square s;
    Shape sh;

    render(c);  // 运行时,shape 引用的是 Circle 对象,调用 Circle::draw()
    render(s);  // 运行时,shape 引用的是 Square 对象,调用 Square::draw()
    render(sh); // 运行时,shape 引用的是 Shape 对象,调用 Shape::draw()
    
    return 0;
}

输出:

Code

Drawing a Circle.
Drawing a Square.
Drawing a generic Shape.

这就是动态绑定的魔力:同一行代码 shape.draw(),根据传入对象的不同,在运行时表现出不同的行为。


  1. 动态绑定的底层实现:虚函数表 (vtable)

既然编译器在编译时不知道该调用哪个函数,那在运行时是如何找到正确版本的呢?答案就是虚函数表(Virtual Table, vtable)**虚函数指针(Virtual Pointer, vptr)

  1. 虚函数表 (vtable)

    • 当一个类拥有(或继承了)虚函数时,编译器会为这个创建一个静态的、唯一的虚函数表。

    • 这个表本质上是一个函数指针数组,存储了该类所有虚函数的地址。派生类如果重写了某个虚函数,其 vtable 的对应位置就会存放重写后的函数地址;如果未重写,则存放继承自基类的函数地址。

  2. 虚函数指针 (vptr)

    • 当一个包含虚函数的类的对象被创建时,编译器会秘密地在该对象内存布局的起始位置添加一个指针,即虚函数指针 (vptr)

    • 这个 vptr 指向该对象所属类的 vtable

动态调用的过程 (以 shape.draw() 为例):

  1. 访问 vptr:通过对象 shape 的地址,找到其头部的 vptr

  2. 访问 vtable:通过 vptr,跳转到该类对应的 vtable

  3. 查找函数地址:在 vtable 中,根据 draw() 函数在表中的固定偏移量(offset),找到正确的函数地址。例如,draw() 可能是表中的第一个函数。

  4. 执行函数:通过该地址调用函数。

这个 vptr -> vtable -> function 的查找过程,就是动态绑定的底层机制。它带来了轻微的运行时开销(一次指针解引用和一次数组索引),但换来了巨大的设计灵活性。


  1. 虚函数与内联 (Inline) 的“矛盾”

现在我们来谈谈您提出的第二个论点:“无法内联”。

为什么通常无法内联?

  • 内联(Inlining) 是一种编译期优化。编译器将函数调用替换为函数体本身的代码,从而避免了函数调用的开销(如栈帧建立、参数传递、跳转等)。

  • 要实现内联,编译器必须在编译时知道要调用哪个具体的函数。

  • 而虚函数的动态绑定,其本质就是推迟到运行时才知道调用哪个函数。

这两者在根本上是矛盾的。当编译器看到 shape.draw() 这样的代码时,它只知道 shapeShape& 类型,但无法预知它会引用 Circle 还是 Square。既然无法确定具体函数,自然也就无法将其内联。

但是,这并非绝对!

现代编译器非常智能,它们拥有一种叫做去虚拟化(Devirtualization)的优化技术。在某些情况下,编译器可以在编译期推断出虚函数调用的确切目标,从而将动态绑定优化为静态绑定,进而使其可以被内联。

可以内联的场景:

  1. 通过对象本身调用

    C++

    Circle c;
    c.draw(); // 编译器100%确定调用的是 Circle::draw()
              // 这里没有多态,是静态绑定,完全可以内联
  2. 编译器能推断出对象的实际类型

    C++

    Shape* s = new Circle();
    s->draw(); // 编译器在一个作用域内能看到对象的创建和使用
               // 它可以推断出 s 指向的一定是 Circle
               // 因此可以去虚拟化,直接调用 Circle::draw(),并且可能内联
    
    delete s;

    这种优化在现代编译器(如 GCC, Clang, MSVC)的较高优化级别(-O2, -O3)下非常常见。

  3. 链接时优化 (LTO) / 全局优化 (WPO): 如果编译器在链接时分析整个程序,发现某个基类指针在所有代码路径中实际上只可能指向某一个特定的派生类,它也可以进行去虚拟化。

  4. 基于预测的优化 (Profile-Guided Optimization, PGO): 通过对程序的实际运行进行分析,如果发现某个虚函数调用点 99% 的时间都在调用同一个派生类的版本,编译器可以生成“快速通道”代码:先检查对象类型是否为预测的类型,如果是,则直接执行内联后的代码;如果不是,再走完整的 vtable 查找流程。

总结与对比

特性 动态绑定调用 (Polymorphic Call) 去虚拟化后的调用 (Devirtualized Call)
发生时机 运行时 编译时
调用方式 base_ptr->virtual_func() derived_obj.virtual_func() 或编译器可推断类型
底层机制 通过 vptr 查找 vtable 直接调用函数地址
性能开销 轻微开销(指针解引用+数组索引) 无额外开销,与普通函数调用相同
内联可能性 通常不能 可以

结论:

  1. “虚函数不能动态绑定”是完全错误的。虚函数是 C++ 实现动态绑定的标准方式。

  2. “虚函数无法内联”是一个普遍的认知,但在很多情况下并不准确。虚函数调用在真正需要多态、无法在编译期确定对象类型的场景下,确实无法内联。但在编译器能够“看穿”对象真实类型的场景下,通过“去虚拟化”优化,虚函数调用可以被转为静态调用,从而享受内联带来的性能提升。

2. 可变数据成员(mutable

mutable关键字允许const成员函数修改特定成员变量,常用于记录内部状态(如调用次数)。

示例:添加access_ctr计数器

class Screen {
public:
    void some_member_func() const;
private:
    mutable size_t access_ctr = 0; // 可变成员,即使const函数也能修改
    // 其他成员...
};

void Screen::some_member_func() const {
    ++access_ctr; // 合法:修改mutable成员
    // 其他操作...
}

适用场景

  • 调试日志记录

  • 缓存标记(如mutable bool cache_valid

  • 线程安全中的原子计数器

“要实现链式调用,必须返回当前对象的引用(Screen&),否则操作的是临时副本,原对象不会被修改。”

class BadDesign {
public:
    BadDesign increment() { count++; return *this; }  // 错误:返回副本
private:
    int count = 0;
};

BadDesign obj;
obj.increment().increment();  // 实际只增加了一次(第二次操作的是临时副本)

8. 最佳实践总结

情景 正确做法 错误做法
const 成员函数 返回 const 引用 返回非 const 引用
需要链式调用 重载非 const 版本 强制去掉 const
代码复用 非 const 版本调用 const 版本 重复实现相同逻辑

9. 完整解决方案代码

class Screen {
public:
    // const版本(安全只读)
    const Screen& display() const {
        for (size_t i = 0; i < contents.size(); ++i) {
            std::cout << contents[i];
            if ((i + 1) % width == 0) std::cout << '\n';
        }
        return *this;
    }

    // 非const版本(支持链式调用)
    Screen& display() {
        return const_cast<Screen&>(
            static_cast<const Screen&>(*this).display()
        );
    }

    // 其他成员...
};

10. 关键结论

  • const 成员函数返回 const 引用是语言规则,确保 const 安全

  • 通过重载实现”读时 const,写时非 const”的灵活设计

  • 类型转换技巧需谨慎使用,必须保证逻辑 const 正确性

7.3.3 友元再探

友元的三种形式

1. 友元函数 (Friend Function)

这是最常见的形式,一个普通的非成员函数被授予访问一个类私有成员的权限。

  • 例子:Sales_data类将read,print,add函数声明为友元。

    class Sales_data {
        // 声明三个非成员函数为友元
        friend std::istream& read(std::istream&, Sales_data&);
        friend std::ostream& print(std::ostream&, const Sales_data&);
        friend Sales_data add(const Sales_data&, const Sales_data&);
    
    public:
        // ... 公有成员
    private:
        std::string bookNo; // 友元函数可以直接访问
        unsigned units_sold = 0; // 友元函数可以直接访问
    };
  • 用途:常用于实现辅助函数,如操作符重载(<<, >>)或工具函数,这些函数在逻辑上与类紧密相关,但又不适合作为成员函数。

2. 友元类 (Friend Class)

一个类可以将其所有成员函数的访问权限授予给另一个类。

  • 例子:Screen类将Window_mgr类声明为友元。

    class Screen {
        // 将 Window_mgr 声明为友元类
        friend class Window_mgr; 
    
    private:
        std::string contents; // Window_mgr 的所有成员函数都可以访问
    };
  • 效果Window_mgr所有成员函数都可以无限制地访问 Screen所有成员(包括 privateprotected)。这是一种“全权委托”式的授权。

  • 用途:适用于两个类需要深度协作,构成一个更大的功能模块的场景。

3. 友元成员函数 (Friend Member Function)

这是最精确的授权方式:只将访问权限授予另一个类的特定成员函数,而不是整个类。

  • 例子:Screen类只将Window_mgr::clear 函数声明为友元。

    // 必须先有 Window_mgr 的声明
    class Window_mgr; 
    
    class Screen {
        // 精确授权给 Window_mgr 类的 clear 成员函数
        friend void Window_mgr::clear(Window_mgr::ScreenIndex);
    
    private:
        std::string contents; // 只有 Window_mgr::clear 可以访问
    };
  • 挑战:声明顺序是关键

    这种方式对代码的组织结构要求极高,因为涉及两个类之间的交叉引用。必须遵循以下步骤:

    1. 前向声明 Window_mgr 类。

    2. 定义 Screen 类,并在内部声明 friend void Window_mgr::clear(...)

    3. 定义 Window_mgr 类,其中声明 clear() 函数,但不能定义它(因为此时 Screen 的完整定义还不可见)。

    4. 在所有类定义之后,定义 Window_mgr::clear() 函数的实现。此时 Screen 的完整定义已知,可以访问其成员。

7.5 构造函数再探

7.5.1 构造函数初始值列表

场景 初始化列表 构造函数体内赋值
const成员 必须使用 编译错误
引用成员 必须使用 编译错误
无默认构造的类成员 必须使用 编译错误
效率(非平凡类型) 更高(直接构造) 更低(构造+赋值)
代码清晰度 更直观 易混淆

初始化列表顺序与成员声明顺序一致

class Example {
    int a;  // 声明顺序决定初始化顺序
    int b;
public:
    Example(int x, int y) : a(x), b(y) {} // 顺序一致
};

避免用成员初始化其他成员

  • 改用构造函数参数直接初始化:

    // 推荐:用参数初始化,不依赖成员顺序
    Example(int x, int y) : a(x), b(y) {}
    
    // 避免:依赖成员初始化顺序
    Example(int x) : a(x), b(a) {} // 危险!
情况 是否用explicit 原因
构造函数逻辑简单 可选 隐式转换可能提高代码简洁性
构造函数可能引发歧义 推荐 避免意外的类型转换(如istream
参数是“资源描述符” 必须 如文件句柄、锁等,避免隐式资源占用
标准库容器类(如vector) 必须 防止误用(如vector<int> v = 42

static 的核心思想:从“对象级别”提升到“类级别”

在 C++ 类中,static 关键字的根本作用,就是将一个成员的“身份”从 “属于每个独立对象” 提升到 “属于整个类”

想象一下,一个类就像一张“汽车设计图纸”,而对象就是根据这张图纸造出来的“每一辆具体的汽车”。

  • 非静态成员 (Non-static members):像是每辆车的 车身颜色发动机序列号。这些属性对于每辆车来说都是独立的、私有的。张三的红色汽车和李四的蓝色汽车,它们的颜色互不影响。这些成员存储在每个对象(每辆车)的内存空间里。

  • 静态成员 (Static members):像是这个车型的“官方指导价”或者“已生产总数”。这个信息不属于任何一辆特定的车,而是属于“这款车”这个概念本身。当官方指导价调整时,所有未售出的车(以及潜在客户关心的价格)都应参考这个新价格。它只在某个地方(比如总部的数据库里)存储一份,而不是在每辆车上都贴一个标签。

这个核心思想引出了静态成员的所有特性。

深度剖析:静态成员的“变”与“不变”

  1. 存储与生命周期的“变”:独立于对象之外

  • 剖析:这是最关键的区别。非静态数据成员随着对象的创建而诞生(分配内存),随着对象的销毁而消亡。但静态数据成员的内存是在程序启动时、进入 main 函数之前就被分配好的,并且直到程序结束时才被释放。它的生命周期和全局变量一样,贯穿程序的整个运行过程。

  • 推论:

    • 不占用对象体积sizeof(Account) 的结果不会包含静态成员 interestRate 的大小。对象里没有它,自然不算它。

  • 可以独立访问:既然不属于任何对象,我们就不需要先创建一个对象才能访问它。最直接、最清晰的方式就是通过类名和作用域解析运算符 :: 来访问,如 Account::getRate()。这明确地告诉读代码的人:“我正在访问一个属于整个类的东西”。

  1. 定义与初始化的“变”:必须在类外显式定义

  • 剖析:为什么必须在类外定义?这和 C++ 的编译链接模型有关。

    • 类定义(通常在 .h 头文件中)是一个“蓝图”,它会被 #include 到多个 .cpp 文件中。

    • 如果允许在类定义内部初始化静态成员(static double interestRate = 0.015;),那么每个包含了该头文件的 .cpp 文件在编译时都会尝试去创建一个 interestRate 变量。当链接器(Linker)试图将这些编译产物合并成一个可执行文件时,它会发现有多个同名的 interestRate 变量,从而引发“多重定义 (multiple definition)”错误。

    • 因此,C++ 规定:声明(declaration)放在类定义里,告诉编译器有这么个东西;而定义(definition)和初始化必须放在且仅能放在一个 .cpp 文件中。这保证了静态成员在整个程序中只有一份实体。

  • 代码实践:这是组织代码的最佳实践。

    Account.h (声明)

    C++

    class Account {
    public:
        // ...
        static double getRate();
    private:
        static double interestRate; // 声明:告诉编译器存在这个变量
        // ...
    };

    Account.cpp (定义)

    C++

    #include "Account.h"
    
    // 定义并初始化:在这里为 interestRate 分配内存并赋予初值
    // 注意,这里不再需要 static 关键字
    double Account::interestRate = 0.015; 
    
    double Account::getRate() {
        return interestRate;
    }
    // ...
  1. 静态成员函数的“不变”性:没有 this 指针

  • 剖析this 指针的本质是一个隐藏的函数参数,它指向调用该成员函数的那个“具体对象”。例如,当 myAccount.display() 被调用时,this 就指向 myAccount 对象。

  • 推论:

    • 静态成员函数属于类,不属于任何特定对象,因此它在被调用时,根本就没有一个“当前对象”的概念。所以,它没有 this 指针。

    • 结果:

      1. 不能直接访问非静态成员:因为它不知道你想访问的是哪个对象的 owneramount

  1. 不能被声明为 constconst 成员函数承诺不修改 this 所指向的对象的状态。既然连 this 都没有,这个承诺也就毫无意义了。

  2. 类内初始化的“特例”:constexpr static

  • 剖析:为什么const static int或constexpr static成员可以在类内初始化?

    • 这是一种编译器优化和便利性设计。对于整型或枚举类型常量静态成员,它的值在编译期就已经完全确定,并且编译器倾向于将使用到它的地方直接替换为这个值(就像 #define 一样),而不需要真的去内存地址里读取。

    • 因为编译器可以“就地替换”,所以它不一定需要一个唯一的内存地址,也就绕开了前面提到的“多重定义”问题。这使得我们可以方便地用它来定义数组大小、作为模板参数等,这些场景都要求在编译时就知道其值。

  • 最佳实践

    :正如文中所说,即使你在类内初始化了,也

    强烈建议

    在对应的

    .cpp

    文件中再次定义它(但不带初始值)。

    C++

    // MyClass.h
    class MyClass {
        static const int size = 100;
        int arr[size]; // 用途1:合法
    };
    
    // MyClass.cpp
    const int MyClass::size; // 定义,但不初始化
    • 为什么要这么做? 如果你某天需要取这个静态常量的地址(例如,const int* p = &MyClass::size;),链接器就必须找到它的唯一定义才能获取地址。如果没有在 .cpp 文件中提供这个定义,链接就会失败。这是一个常见的“隐藏”错误。

  1. 使用场景的“特权”:静态成员的特殊用途

  • 作为不完全类型的成员

    C++

    class Node {
        // 非静态成员只能是指针或引用,因为编译器不知道 Node 对象多大,
        // 无法为它分配空间。但指针大小是固定的。
        Node* next; 
    
        // 静态成员可以,因为它不存储在对象内部,
        // 编译器不需要在计算 Node 大小时考虑它。
        static Node entry_point; 
    };
  • 作为默认参数

    C++

    class Screen {
        // 错误:非静态成员 background 属于某个对象,
        // 但在声明默认参数时,没有对象存在,编译器不知道去哪取值。
        // void clear(char c = background); 
    
        // 正确:静态成员 shared_char 属于类,
        // 编译器总能通过 Screen::shared_char 找到它。
        void clear(char c = shared_char);
    private:
        char background;
        static char shared_char;
    };

总结

特性 非静态成员 (Non-Static) 静态成员 (Static) 核心原因
归属 每个独立的对象 整个类 static 关键字改变了成员的“身份”
内存 存储于每个对象实例中 独立于对象,在静态存储区仅存一份 身份决定了其存储方式
生命周期 与对象的生命周期一致 与程序的生命周期一致 存储方式决定了其生命周期
访问 对象.成员对象指针->成员 类名::成员 (推荐) 或 对象.成员 必须通过一个实体来访问
this 指针 成员函数中有 this 指针 静态成员函数中没有 this 指针 this 指向调用函数的对象,静态函数不与特定对象绑定
定义/初始化 在构造函数中初始化 在类外定义和初始化 (特例除外) 防止在链接时出现多重定义错误

constexpr 的核心思想:将计算从“运行时”提前到“编译时”

在 C++ 中,constexpr (Constant Expression,常量表达式) 关键字的引入,是为了实现一个强大的目标:让编译器在编译代码的时候,就能够执行某些计算,并将结果直接嵌入到最终的程序中。

这带来了两大好处:

  1. 性能提升:如果一个复杂的计算结果是固定的,那么在每次程序运行时都计算一遍就是一种浪费。constexpr 允许这个计算在编译时只进行一次,程序运行时直接使用最终结果,没有任何计算开销。

  2. 更强的静态检查和编程能力:编译器可以在编译阶段就验证某些值的正确性,例如用计算结果来定义数组大小、模板参数等,这些操作都要求值在编译期是确定的。

深度剖析 字面值常量类 (Literal Constant Class)

您提供的文本详细列出了一个自定义类(class)要成为“字面值类型”所需满足的条件。我们逐条深入解析:

  1. 数据成员都必须是字面值类型

  • 剖析:这非常符合逻辑。如果一个类的“零件”(数据成员)中,有一个不是编译器在编译时能理解和处理的(即不是字面值类型),那么由这些零件组装起来的整个类对象,编译器自然也无法在编译时处理它。

  • 例子int, double, char, 指针,以及其他符合这些规则的 constexpr 类,都是字面值类型。但像 std::stringstd::vector (在 C++20 之前) 这种需要在运行时动态分配内存的类,就不是字面值类型。

  1. 类必须至少含有一个 constexpr 构造函数

  • 剖析:这是创建 constexpr 对象的入口。如果没有一个构造函数被标记为 constexpr,编译器就不知道如何“在编译时”创建一个该类的对象。这个 constexpr 构造函数就是你给编译器的“装配说明书”。

  1. 类内初始值规则

  • 剖析:如果数据成员有类内初始值(例如 int x = 10;),这个初始值也必须是常量表达式。这保证了即使不通过构造函数显式初始化,成员的默认状态在编译期也是已知的。

  1. 必须使用默认的析构函数

  • 剖析:析构函数负责销毁对象。如果用户自定义了析构函数,通常意味着需要执行一些复杂的操作(如释放资源、写日志等),这些都是运行时的行为。要求使用默认析构函数,是保证对象的销毁过程足够简单,不涉及任何只有在运行时才能完成的复杂逻辑,从而让整个生命周期都能被编译器理解。

深度剖析 constexpr 构造函数

这是整个机制的核心。

  • “构造函数不能是 const 的,但可以是 constexpr 的”

    • const 成员函数承诺不修改对象的数据成员。但构造函数的唯一工作恰恰就是初始化(可以视为一种“修改”)数据成员。所以构造函数天然不能是 const 的。

    • constexpr 则是一个完全不同的承诺。它承诺的是:“如果你给我编译期常量作为参数,那么我可以在编译期完成对象的构造”。

  • 函数体通常为空

    • C++ 标准规定,constexpr 函数(在 C++11/14 中)体内基本只能有一条 return 语句。

    • 构造函数没有返回值,所以它不能有 return 语句。

    • 结合这两点,一个 constexpr 构造函数的函数体就只能是空的。它所有的工作都必须在成员初始化列表中完成。

  • 初始化列表是关键

    • constexpr 构造函数的所有数据成员都必须在初始化列表中被初始化。

    • 用于初始化的值,必须是常量表达式。

总结

constexpr 类和构造函数是 C++ 编译期元编程的基石。它允许你将自定义的数据结构(如坐标点、颜色、简单的字符串等)提升到和内置类型(如 int)一样的地位,使它们能够参与到编译期的计算和类型系统中。

您提供的文字完美地总结了其背后的规则,理解这些规则,就能掌握在 C++ 中编写出更高性能、更安全代码的强大工具。

发表评论