c++ core guidelines解析--让接口易于使用

文章目录

参考链接:mp白-c++ core guidelines解析-知乎链接

接口是服务的提供者和使用者之间的契约,契约指定了函数的前置条件、后置条件和不变式,并可以在运行期进行检查。

总结就是:让接口易于使用,难以错误使用。

避免非const的全局变量

如标题,为什么呢?因为全局变量会注入隐藏的以来,而该依赖并不是接口的一部分,如下代码:

cpp 复制代码
int g = 2011;

int func(int fac)
{
    g *= g;
    return g * fac;
}

函数func的执行有一个副作用,会改变全局变量的值。因此,你无法对函数进行孤立测试。当更多线程需要使用这个函数的时候,就必须对g变量加以保护。因此,如果这个函数不更改全局变量的值,则我们可以为了性能把函数调用的结果存储到缓存中加以复用[1]。

举个例子:什么情况才可能会是我们说的:没有副作用,那你可以为了性能而将之前的结果存储到缓存中以进行复用

cpp 复制代码
int add(int a,int b)
{
    return a + b;
}

int main()
{
    int c = add(1, 2) + add(1, 2) + add(1, 3);
}

其实无非就是:编译器看到你用同样的入参调用了两次 就可以干掉第二次调用。之前的结果被缓存了,前提是这得是纯函数。

但是我们前面依赖了全局变量,就不行,它有外部的副作用,返回的结果可能会根据全局变量的不同而不同,没办法缓存。不能保证:多次调用传入的数据相同就能得到完全一致的结果

弊端

非const的全局变量有很多弊端。

  • 可测试性:无法孤立测试你的实体。如果单元不存在,则单元测试也不存在。只能进行系统测试。实体的执行效果要依赖整个系统的状态。
  • 重构:无法孤立地对代码进行推理,重构会有相当大的挑战。
  • 优化:你无法轻易地重新安排函数调用或者在不同的线程上进行函数调用,因为可能有隐藏的依赖。缓存之前函数调用的结果也极为危险。
  • 并发:产生数据竞争的必要条件是有共享而可变的状态。而非 const 全局变量正是共享而可变的。

隐藏的单例示例

cpp 复制代码
// singleton.cpp

#include <iostream>

class MySingleton{
public:
    MySingleton(const MySingleton&) = delete;
    MySingleton& operator = (const MySingleton&) = delete;

    static MySingleton* getInstance(){
        if(!instance){
            instance = new MySingleton();
        }
        return instance;
    }

private:
    static MySingleton* instance;
    MySingleton() = default;
    ~MySingleton() = default;
};

MySingleton* MySingleton::instance = nullptr;

单例就是全局变量,因此你应当尽可能 _避免单例 _。单例模式 的核心是确保一个类只有一个实例,并提供一个全局访问点。但正因为它是"全局可访问"的,它本质上就是一个全局变量 。全局变量的问题在于:它们隐藏了依赖关系 ,让代码更难理解、测试和维护。所以,即使单例看起来方便,也应该尽量避免使用

作为全局变量,单例注入了一个依赖,而该以来忽略了函数的接口。这是因为作为静态变量,单例通常会被直接使用,正如上面例子主函数中的两行所展示的那样:Singleton::getInstance(),而对单例的调用也有一些严重后果。你无法对有单例的函数进行单元测试,因为单元不存在。

此外,你也不能创建单例的伪对象,并在运行期替换它,因为单例并不是函数接口的一部分。

书中原句:

作为全局变量,单例注入了一个依赖,而该依赖忽略了函数的接口。

解读:

正常来说,一个函数如果依赖某个对象,应该通过参数传递进来(即依赖注入)。但单例是直接在函数内部调用的,比如 Singleton::getInstance()->doSomething()。这意味着函数的签名(接口)没有体现它依赖了什么,依赖被隐藏了。这会让函数的可测试性变差,因为你无法从外部控制它依赖的对象。

依赖被隐藏,意味着什么?

1. 正常的依赖注入(不隐藏依赖)

cpp 复制代码
// 接口
class Logger {
public:
    virtual void log(const std::string& msg) = 0;
};

// 实现
class ConsoleLogger : public Logger {
public:
    void log(const std::string& msg) override {
        std::cout << msg << std::endl;
    }
};

// 业务代码:依赖通过参数传进来
void checkout(Logger* logger, int amount) {
    logger->log("checkout begin");
    // ... 真正的业务 ...
    logger->log("checkout end");
}

测试时想换"假"日志器:

cpp 复制代码
class FakeLogger : public Logger {
public:
    std::vector<std::string> msgs;
    void log(const std::string& msg) override {
        msgs.push_back(msg);   // 只记录,不打印
    }
};

TEST(Checkout, BasicFlow) {
    FakeLogger fake;
    checkout(&fake, 100);      // 把假对象传进去
    EXPECT_EQ(fake.msgs.size(), 2);
}

如你所见:

  • 函数签名 checkout(Logger* logger, int amount)把依赖摆在明面上
  • 测试想换谁就换谁,完全可控
  1. 用单例(隐藏依赖)
cpp 复制代码
// 单例
class LoggerSingleton {
public:
    static LoggerSingleton& getInstance() {
        static LoggerSingleton inst;
        return inst;
    }
    void log(const std::string& msg) {
        std::cout << msg << std::endl;
    }
private:
    LoggerSingleton() = default;
};

// 业务代码:依赖被"藏"在函数体里
void checkout(int amount) {
    LoggerSingleton::getInstance().log("checkout begin");
    // ... 真正的业务 ...
    LoggerSingleton::getInstance().log("checkout end");
}

现在问题来了:

  1. 函数签名是 checkout(int amount)看上去它只需要一个整数
    实际上它偷偷依赖了 LoggerSingleton,但接口里没有任何提示
  2. 测试时你想让它别往屏幕打印,而是把日志录下来:
    做不到! 因为LoggerSingleton::getInstance()写死在代码里,
    无法在测试时塞一个"假"的单例进去(C++ 静态变量生命周期由编译器掌控)。

书中原句:

作为静态变量,单例通常会被直接调用,正如上面例子主函数中的两行所展示的那样:Singleton::getInstance()。

解读:

单例的实例通常是静态变量 ,通过类名直接访问。这种调用方式是硬编码的写死在代码里 。结果就是:你无法在不修改代码的情况下替换它

书中原句:

而对单例的直接调用有一些严重的后果。你无法对有单例的函数进行单元测试,因为单元不存在。

解读:

单元测试的核心是隔离测试单个模块 。但如果一个函数内部偷偷调用了单例 ,你就无法控制它的行为 。比如单例可能访问数据库、文件系统或网络,这些都不应该在单元测试中出现。所以,这个函数就不再是一个"纯单元",测试它就会变得困难甚至不可能。

被测函数(用单例)
cpp 复制代码
// 单例:访问真实数据库
class ConfigSingleton 
{
public:
    static ConfigSingleton& instance() 
    {
        static ConfigSingleton inst;
        return inst;
    }
    int taxPercent() {              // 真的会去查 DB
        return queryFromDatabase();
    }
};

// 要测的"单元"
double calcPrice(double amount) {
    int tax = ConfigSingleton::instance().taxPercent();  // ← 偷偷查库
    return amount * (1 + tax / 100.0);
}

只想测"计算逻辑":如果税率是 10%,100 元应该得到 110 元。

现实却变成
  • calcPrice 一跑,就去连真正的数据库
  • 数据库可能连不上、表里可能没数据、税率可能不是 10%;
  • 测试结果一会儿红一会儿绿,失败原因跟"计算逻辑"完全无关

于是:"单元"测试早已不再是"单元"测试 ,它变成了"数据库 + 网络 + 业务"一大坨集成测试。只要单例还藏在里面,就永远剪不断这根绳子没法把"纯逻辑"单独拎出来跑

而如果不依赖单例,显式注入依赖,把原来在函数内部依赖单例的那一步,改成由调用者通过参数明明白白地送进来。

cpp 复制代码
#include <iostream>
#include <cassert>

class TaxProvider {
public:
    virtual ~TaxProvider() = default;
    virtual int taxPercent() const = 0;
};

class DbTaxProvider : public TaxProvider {
public:
    int taxPercent() const override {
        return queryFromDatabase();   
    }
private:
    int queryFromDatabase() const {
        // 这里连数据库,假设返回 10
        return 10;
    }
};

double calcPrice(double amount, const TaxProvider& taxProv) {
    int tax = taxProv.taxPercent();
    return amount * (1 + tax / 100.0);
}

class FakeTaxProvider : public TaxProvider {
public:
    explicit FakeTaxProvider(int fixed) : tax_(fixed) {}
    int taxPercent() const override { return tax_; }
private:
    int tax_;
};

void testCalcPrice() {
    FakeTaxProvider fake(10);          // 固定税率 10%
    double price = calcPrice(100.0, fake);
    assert(price == 110.0);            // 只测逻辑
    std::cout << "[TEST] 100 * 1.10 = " << price << "  ✔\n";
}

int main() {
    testCalcPrice();                  

    DbTaxProvider realTax;             
    double p = calcPrice(100.0, realTax);
    std

至此:

  • 代码里没有任何单例
  • 测试部分完全不碰数据库
  • 生产部分可随意扩展 (网络、文件、DB 都行),只需再写一个新的 TaxProvider 实现即可。

书中原句:

此外,你也不能创建单例的伪对象并在运行期替换,因为单例并不是函数接口的一部分。

解读:

如果依赖是通过接口传进来的,你可以在测试时传一个假的(mock)对象 。但单例是硬编码在函数内部 的,不是参数不是接口 。所以你无法在运行时替换它 ,也就无法模拟它的行为

最后:

实现单例看似小事一桩,但其实不然。你将面对几个挑战:

  • 谁来负责单例的销毁?
  • 是否应该允许从单例派生?
  • 如何以线程安全的方式初始化单例?
  • 当单例互相依赖并属于不同的翻译单元时,应该以何种顺序初始化这些单例?这里要吓唬吓唬你了。这一难题被称为静态初始化顺序问题。

运用依赖注入化解

当某个对象使用单例的时候,注入的依赖就被注入对象中。而借助依赖注入技术,这个依赖可以变成接口的一部分,并且服务时从外界注入的。这样,客户代码和注入的服务之间就没有依赖了。依赖注入的典型方式是构造函数、设置函数(setter)成员或模板参数。

下面的程序展示了如何使用依赖注入替换一个日志记录器:

cpp 复制代码
#include<iostream>
#include <chrono>
#include <memory>

class Logger{
public:
    virtual void write(const std::string&) = 0;
    virtual ~Logger() = default;
};

class SimpleLogger:public Logger{
    void write(const std::string& mess) override{
        std::cout << mess << std::endl;
    }
};

class TimeLogger:public Logger{
    using MySecondTick = std::chrono::duration<long double>;
    long double timeSinceEpoch(){
        auto timeNow = std::chrono::system_clock::now();
        auto duration = timeNow.time_since_epoch();
        MySecondTick sec(duration);
        return sec.count();
    }
    void write(const std::string& mess) override{
        std::cout << std::fixed;
        std::cout << "Time since epoch: " << timeSinceEpoch() << ": " << mess << std::endl;
    }
};

class Client{
public:
    Client(std::shared_ptr<Logger>log) :logger(log) {}
    void doSomething(){
        logger->write("Message");
    }
    void setLogger(std::shared_ptr<Logger>log){
        logger = log;
    }
private:
    std::shared_ptr<Logger>logger;
};

int main(){
    Client cl(std::make_shared<SimpleLogger>());  //(1)
    cl.doSomething();
    cl.setLogger(std::make_shared<TimeLogger>()); // (2)
    cl.doSomething();
    cl.doSomething();

    std::cout << std::endl;
}

客户代码 cl 支持用构造函数(1)和成员函数 setLogger(2)来注入日志记录服务。

与 SimpleLogger 相比,TimeLogger 还在它的信息中包含了自 UNIX 纪元以来的时间。

cpp 复制代码
Message
Time since epoch: 1697943951.168827: Message
Time since epoch: 1697943951.171675: Message

构建良好的接口

函数应该通过接口(而不是全局变量)进行沟通

  • 接口明确
  • 接口精确并且具有强类型
  • 保持较低的参数数目
  • 避免相同类型却不相关的参数相邻

下面的函数 showRectangle 违反了刚提及的接口的所有规则:

cpp 复制代码
void showRectangle(double a,double b,double c,double d){
    a = floor(a);
    b = ceil(b);

    ...
}
void showRectangle(Point top_left, Point bottom_right); // 好

尽管函数 showRectangle 本应当只显示一个矩形,但修改了它的参数。实质上它有两个目的,因此,它的名字有误导性(接口应明确)。另外,函数签名没有提供关于参数应该是什么的任何信息,也没有关于应该以什么顺序提供参数的信息(接口应精确并且具有强类型和 应保持较低的参数数目)。此外,参数是没有取值范围约束的双精度浮点数。因此,这种约束必须在函数中确立(应避免相同类型却不相关的参数相邻)。

对比而言,第二个 showRectangle 函数接受两个具体的点对象(Point)。

检查 Point是否合法值是 Point 构造函数的工作。这种检查工作本来就不是函数 showRectangle 的职责

不要用单个指针来传递数组

这是一条非常特殊的规则,肯定会有很多人不屑一顾。这条规则的出现正是为了解决一些未定义行为。例如下面的函数 copy_n 相当容易出错。

cpp 复制代码
template<typename T>
void copy_n(const T* p, T* q, int n); // 从[p:p+n] 拷贝到 [q:q+n]

...

int a[100] = {0,};
int b[100] = {0,};

copy_n(a, b, 101);

也许某一天累得精疲力尽,就数错了一个。结果会引发一个元素的越界错误,造成未定义行为。补救方法也很简单,使用 STL 中的容器,如 std::vector ,并在函数体中检查容器大小。C++20 提供的 std::span 能更优雅地解决这个问题。std::span 是个对象,它可以指代连续存储的一串对象。 std::span 永远不是所有者(其实就是说它是个视图,没所有权)。而这段连续的内容可以是数组,或是带有大小的指针,或是 std::vector。

函数传参数组不用指针,而是用 C++20 的 std::span

cpp 复制代码
template<typename T>
void copy(std::span<const T>src, std::span<T> des);

int arr1[] = {1, 2, 3};
int arr2[] = {1, 2, 3};

copy(arr1,arr2);

copy 不需要元素的数目。一种常见的错误来源就这样被 std::span<T> 消除了。

为了库 ABI 的文档,考虑使用 PImpl

由于私有数据成员参与类的内存布局,而私有成员函数参与重载决议,**对这些实现细节的改动都要求使用了这类的所有用户全部重新编译**。而持有指向实现的指针(Pimpl)的 非多态的接口类,则可以将类的用户从其实现的改变隔离开来,**而代价是一层间接**。

cpp 复制代码
// Widget.h
class widget {
    class impl;
    std::unique_ptr<impl> pimpl;
public:
    void draw(); // 公开 API 转发给实现
    widget(int); // 定义于实现文件中
    ~widget();   // 定义于实现文件中,其中 impl 将为完整类型
    widget(widget&&) noexcept; // 定义于实现文件中
    widget(const widget&) = delete;
    widget& operator=(widget&&) noexcept; // 定义于实现文件中
    widget& operator=(const widget&) = delete;
};

// Widget.cpp
class widget::impl {
    int n; // private data
public:
    void draw(const widget& w) { /* ... */ }
    impl(int n) : n(n) {}
};
void widget::draw() { pimpl->draw(*this); }
widget::widget(int n) : pimpl{std::make_unique<impl>(n)} {}
widget::widget(widget&&) noexcept = default;
widget::~widget() = default;
widget& widget::operator=(widget&&) noexcept = default;
相关推荐
亭上秋和景清2 小时前
指针进阶: 回调函数
开发语言·前端·javascript
helloworddm2 小时前
NSIS编写C/C++扩展
c语言·开发语言·c++
Vanranrr2 小时前
一个由非虚函数导致的隐藏Bug:窗口显示异常问题排查与解决
开发语言·bug
ULTRA??2 小时前
QT向量类实现GJK碰撞检测算法3d版本
c++·qt·算法
煤球王子2 小时前
学而时习之:C++ 中的文件处理
c++
天赐学c语言2 小时前
12.10 - 合并两个有序链表 && 对字节对齐的理解
数据结构·c++·leetcode·链表
曹牧2 小时前
Java:Jackson库序列化对象
java·开发语言·python
仰泳的熊猫2 小时前
1092 To Buy or Not to Buy
数据结构·c++·算法·pat考试
CSDN_RTKLIB2 小时前
解除vcpkg对VS的全局配置注入
c++