C++ 如何优雅解决类的循环依赖?

一、引言

C++ 经常会遇到一个棘手的编译问题:两个类之间存在相互引用或调用的情况。就是那种“你中有我,我中有你”的关系!设计上合理,但 C++编译器的严格规则下,却会有编译错误。

比如:

  • Promise 类的方法要返回 Future 类型的对象。
  • Future 类的方法要持有或访问 Promise 类型的对象。

这种相互依赖很难决定哪个类应该先被定义。

编译器会报出未声明的类型 或 成员变量类型不完整 的错误。

这些报错的原因是因为C++编译器的基本工作方式:单遍扫描 。编译器自上而下读取代码要知道一个类型的完整定义才能:

  • 创建该类型的对象:因为要知道对象的大小,以便在内存分配空间。
  • 访问该类型的成员函数或成员变量:因为要知道类的内存布局和成员的偏移量。

但是,如果只是声明一个指向该类型的指针或引用 ,编译器只要知道这个类型的名字 就可以,因为所有指针和引用的大小是固定的。

于是,陷入了一个“先有鸡还是先有蛋”的问题。

在这里插入图片描述

二、前置声明和延后实现

无法改变编译器的“单遍扫描”,那就用 C++ 的语法特性来“欺骗”编译器的时序感。

解决循环依赖的策略分三步:前置声明只声明不定义 以及 延后实现

逻辑就是:先告诉编译器“名字”的存在,可以用指针或引用;等所有类的都构建完毕,再回头去填充具体的函数逻辑。

Step 1: 前置声明。

在文件最开头告诉编译器:“有一个类,具体细节稍后给出,先声明名字。”

template <typename T>
class Future; 

有了这一行,就可以在 Promise 声明 Future<T>*Future<T>&,在函数返回值写 Future<T>(只要不立即生成对象)。

Step 2: 声明但不定义。

在类内部绝对不要写那些依赖完整定义的函数体。 否则编译器会报错,因为它此时只知道 名字,不知道类的构造函数长什么样,也不知道占用多少内存。

所以,只写函数原型:

template <typename T>
class Promise {
public:
    // 只声明,不写函数体!
    Future<T> GetFuture(); 

    // 其他不依赖 Future 的方法可以正常实现
    void SetValue(const T& val) { /* ... */ }
};

Step 3: 延后实现。 完整定义类。

// 定义 Future 类
template <typename T>
class Future {
public:
    // Promise 已经定义完整,直接用
    explicit Future(Promise<T>* p) : promise_(p) {}
    
private:
    Promise<T>* promise_;
};

// --- 延后实现 ---
template <typename T>
Future<T> Promise<T>::GetFuture() {
    return Future<T>(this); // 终于可以写函数体了
}

实现纯头文件会根据类是否是模板类,处理方式略有不同。

  • 模板类。 上面的 Promise<T>Future<T> 都是模板。C++ 标准规定,模板的定义必须对用它的每个翻译单元可见。所以,把上面所有代码(声明、定义、延后实现)都写在同一个 .hpp 文件是完全合法的,不用额外的关键字。
  • 普通类。 如果写的不是模板类,而是普通的 class Promiseclass Future,并且把函数的实现放在了类定义之外(即 .hpp 文件的下方),那么必须加上 inline 关键字。如果不加 inline,这个 .hpp 被多个 .cpp 文件包含时,链接器会发现 Promise::GetFuture 被定义多次,报出“多重定义”错误。

普通类的写法:

class Future; // 前置声明

class Promise {
public:
    Future GetFuture(); // 声明
};

class Future {
public:
    Promise* p;
    Future(Promise* _p) : p(_p) {}
};

// 必须加 inline,否则链接报错!
inline Future Promise::GetFuture() {
    return Future(this);
}

通过前置声明让编译器认识名字,通过类内只声明避开类型不完整的检查,最后通过类外延后实现完成逻辑闭环。

这套方法完美解决语法层面的报错,让代码能够顺利编译。

但是,细心点的会注意到:Promise 持有 Future 的生产能力,而 Future 持有 Promise 的指针,这种双向强耦合在资源管理和生命周期上是否安全?如果 Promise 先被销毁了,Future 手里的指针岂不是变成了悬垂指针?

三、原理深挖——编译器在想什么?

要真正理解为什么前置声明和延后实现能够解决循环依赖问题,就要深入了解 C++ 编译器在处理类型定义时的一些基本原则。这不只是语法的记忆,也是对 C++ 语言底层机制的洞察。

不完整类型 : 编译器遇到一个类的前置声明,它只知道 Future 是一个类型名。它不知道 Future 类内部有什么成员变量、有什么成员函数,也不知道这个类对象在内存中会占用多少字节。这时,Future 就是一个不完整类型

对于不完整类型,编译器能做的事情非常有限:

  • 声明指向它的指针或引用Future* p;Future& r;。因为所有的指针和引用在内存中都占用固定的字节数,编译器不用知道 Future 的具体大小就能为指针或引用分配空间。
  • 声明函数原型Future GetFuture();。函数原型只涉及参数和返回值的类型名称,不涉及实际的对象创建或成员访问。

完整类型: 编译器遇到一个类的完整定义(包含所有成员变量和成员函数的声明)才能确定这个类的完整结构、大小以及内存布局。这时,Future 就是一个完整类型

对于完整类型,编译器可以做所有的事情:

  • 创建对象实例Future f;。编译器知道 f 要多少内存。
  • 访问成员变量或成员函数f.member_var;f.member_func();。编译器知道 member_varmember_funcf 对象内存的偏移量。
  • 作为基类或派生类

现在回到 Promise::GetFuture() 这个方法:

// 在 Promise 类内部
Future<T> GetFuture(); // 声明

// 在 Promise 类外部
template <typename T>
Future<T> Promise<T>::GetFuture() {
    return Future<T>(this); // 实现
}

Promise 类内部声明 GetFuture()Future<T> 只是一个不完整类型(因为只有前置声明)。编译器处理 Promise 类的成员函数声明只关心 GetFuture 的签名(参数类型和返回类型)。因为 Future<T> 是作为返回类型,不涉及实际对象的创建或成员访问,所以编译器允许这个声明通过。

Promise 类外部实现 GetFuture() ,编译器处理 Promise<T>::GetFuture() 的函数体 return Future<T>(this); 要做:

  1. 构造一个 Future<T> 对象:要求 Future<T> 必须是一个完整类型,这样编译器才知道如何调用其构造函数,以及为这个对象分配多少内存。
  2. 涉及到 Future<T> 内部成员的访问:如果 Future<T> 的构造函数要访问 Promise<T> 的某些成员,或者 Future<T> 内部有复杂的逻辑,编译器也要这些信息。

因为是 Future 类的完整定义之后才去实现 Promise<T>::GetFuture(),编译器已经完全了解Future<T> 的所有细节,已经是一个完整类型。所以,所有关于 Future<T> 对象的创建、构造函数调用等操作都可以顺利进行,不再报错。

把译器的行为类比:

  • “不耐心”:当它在类定义内部遇到任何需要知道类型大小或成员布局的操作(如定义成员变量、创建局部对象),而该类型尚未完整定义时,它会立即报错。因为它需要这些信息来确定当前类的内存布局。
  • “耐心”:当它遇到一个函数声明(无论是成员函数还是自由函数),只要参数和返回值的类型名是已知的(即使是不完整类型),它就会“忍住”,先不报错。它知道函数体会在稍后被解析,届时这些类型可能会变得完整。这就是为什么我们可以声明 Future<T> GetFuture();

通过这种“先告诉名字,再补充细节”的策略,规避编译器的“不耐心”,利用它的“耐心”,从而在语法层面解决了类之间的循环依赖问题。

虽然这种方法解决了编译问题,但它并没有从根本上解决设计上的耦合问题。

四、进阶:架构层面的解耦

前置声明和延后实现虽然解决 C++ 编译器在语法层面的报错,代码能够顺利编译。但是,这种解决方案只是语法糖,并没有从根本上解决设计的问题。

再次来重新看一下 PromiseFuture 之间的关系:

  • Promise 要知道 Future 的存在,以便创建并返回它。
  • Future 要知道 Promise 的存在(或其句柄),以便最终获取 Promise 设置的结果。

如果 Future 内部直接持有 Promise 的裸指针或引用,那么 Promise 对象生命周期结束,而 Future 仍然存活并尝试访问该指针就会导致悬垂指针。即使用 std::shared_ptr,如果 PromiseFuture 互相持有对方的 shared_ptr,也会形成循环引用 ,造成内存泄漏。

C++ 的std::promisestd::future 的实现就提供了一个非常不错的解决方案,彻底打破 PromiseFuture 之间的直接依赖,通过引入一个中间层 —— 共享状态 来实现解耦。

在这里插入图片描述

核心思想: PromiseFuture 不再直接互相持有或依赖,都转去依赖一个第三方的、独立的共享状态对象。这个共享状态对象负责存储异步操作的结果(或异常),以及管理结果的就绪状态和同步机制。

共享状态的组成:

  • 结果值T value_ (或 std::optional<T>),存储 Promise 设置的最终结果。
  • 就绪标志bool ready_,指示结果是否已经准备好。
  • 异常指针std::exception_ptr exception_,存储 Promise 抛出的异常。
  • 同步原语std::mutex mutex_std::condition_variable cv_,保护共享状态的并发访问,并允许 Future 阻塞等待结果。

在这里插入图片描述

引入 SharedState 后,依赖关系是如何变化的:

旧关系 (双向强耦合)

+---------+       +---------+
| Promise | <---> | Future  |
+---------+       +---------+

Promise 返回 FutureFuture 持有 Promise 的指针)

新关系 (通过中间层解耦)

      +-------------+
      | SharedState |
      +-------------+
       ^           ^
      /             \
     /               \
+---------+       +---------+
| Promise |       | Future  |
+---------+       +---------+

PromiseFuture 都持有 SharedStatestd::shared_ptr

异步操作 解耦后的架构

1 创建并返回 (传递 shared_ptr)

2 持有 std::shared_ptr

3 持有 std::shared_ptr

Promise

Future

PromiseFutureSharedState

定义 SharedState:这个类不依赖 PromiseFuture。内部包含结果、就绪标志和同步原语。

#include <memory>
#include <mutex>
#include <condition_variable>
#include <exception>

template <typename T>
class PromiseFutureSharedState {
private:
    T value_;
    bool ready_ = false;
    std::exception_ptr exception_;
    std::mutex mutex_;
    std::condition_variable cv_;

public:
    void SetValue(const T& val) {
        std::lock_guard<std::mutex> lock(mutex_);
        value_ = val;
        ready_ = true;
        cv_.notify_one();
    }

    void SetException(std::exception_ptr ex) {
        std::lock_guard<std::mutex> lock(mutex_);
        exception_ = ex;
        ready_ = true;
        cv_.notify_one();
    }

    T GetValue() {
        std::unique_lock<std::mutex> lock(mutex_);
        cv_.wait(lock, [this]{ return ready_; });
        if (exception_) {
            std::rethrow_exception(exception_);
        }
        return value_;
    }
};

修改 FutureFuture 不再持有 Promise 的指针,而是持有 SharedStatestd::shared_ptr

#include <memory>

template <typename T>
class PromiseFutureSharedState;

template <typename T>
class Future {
private:
    std::shared_ptr<PromiseFutureSharedState<T>> shared_state_;
public:
    // Future 构造时接收 shared_ptr
    explicit Future(std::shared_ptr<PromiseFutureSharedState<T>> state)
        : shared_state_(state) {}

    T Get() {
        return shared_state_->GetValue();
    }
};

修改 PromisePromise 也持有 SharedStatestd::shared_ptr。当 Promise 创建 Future 时,它会把自己的 shared_state_ 传递给 Future

template <typename T>
class Promise {
private:
    std::shared_ptr<PromiseFutureSharedState<T>> shared_state_;
public:
    Promise() : shared_state_(std::make_shared<PromiseFutureSharedState<T>>()) {}

    // 共享状态传递过去
    Future<T> GetFuture() {
        return Future<T>(shared_state_);
    }

    void SetValue(const T& val) {
        shared_state_->SetValue(val);
    }

    void SetException(std::exception_ptr ex) {
        shared_state_->SetException(ex);
    }
};

优势:

  • 彻底打破编译依赖PromiseFuture 都不用知道对方的完整定义,只要知道 SharedState 的完整定义。SharedState 自身不依赖 PromiseFuture。头文件包含顺序变得简单:先定义 SharedState,再定义 FuturePromise
  • 通过 std::shared_ptrSharedState 的生命周期由所有持有它的 PromiseFuture 实例共同管理。只要有一个 PromiseFuture 存活,SharedState 就会存活,避免悬垂指针和内存泄漏(除非有循环 shared_ptr,但这里 PromiseFuture 只是各自持有 SharedState,不存在它们之间的循环引用)。
  • 职责分离
    • Promise 的职责是“设置结果”。
    • Future 的职责是“获取结果”。
    • SharedState 的职责是“存储和同步结果”。
  • SharedState 内部封装了 mutexcondition_variable,支持多线程环境的并发访问。

五、结语

C++ 纯头文件库的类之间的循环依赖是很常见的问题。

C++编译器单遍扫描的工作机制是导致“类型未声明”或“不完整类型”错误的原因。两个类互相要对方的完整定义会让编译器陷入“先有鸡还是先有蛋”的困境。

为应对编译器的这一限制,可以用前置声明和延后实现这样的标准语法技巧。

从根本上消除设计上的双向强耦合。要用共享状态(Shared State)模式 ,打破编译依赖,实现架构层面的解耦。

在这里插入图片描述

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Lion 莱恩呀

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值