文章目录
参考链接: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)把依赖摆在明面上。 - 测试想换谁就换谁,完全可控。
- 用单例(隐藏依赖)
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");
}
现在问题来了:
- 函数签名是
checkout(int amount),看上去它只需要一个整数 ;
实际上它偷偷依赖了LoggerSingleton,但接口里没有任何提示。 - 测试时你想让它别往屏幕打印,而是把日志录下来:
做不到! 因为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;