接口
接口性规则概览:
接口是服务的提供者和使用者之间的契约。根据 C++ Core Guidelines,接口”可能是代码辅助中最重要的一个方面”。“接口”这一部分大约有 20 条规则。
- 让接口易于正确使用,难以错误使用。
I.2 避免非 const 的全局变量
当然,你应该避免非 const 的全局变量。但是为什么呢?为什么全局变量(尤其是当它不是常量时)会很糟糕?全局变量会在函数中注入隐藏的依赖,而该依赖并不是接口的一部分。下面的代码片段说明了我的观点:
加粗的话注意理解。
int glob{ 2011 };
int multiply(int fac){
glob *= glob;
return glob * fac;
}
函数 multiply 的执行有一个副作用——会改变全局变量 glob 的值。因此,你无法对函数进行孤立测试或推理。当更多的线程并发地使用 multiply 时,你就必须对变量 glob 加以保护。非 const 的全局变量还有更多其他弊端。如果函数 multiply 没有副作用,那你可以为了性能而将之前的结果存储到缓存中以进行复用。
注意到我们加粗的内容了吗?这非常重要,自行理解。 我们就讲一下最后一句,这其实是在描述编译器优化 ,我们举个例子:什么情况才可能会是我们说的:没有副作用,那你可以为了性能而将之前的结果存储到缓存中以进行复用。
int add(int a,int b){
return a + b;
}
int main(){
int c = add(1, 2) + add(1, 2) + add(1, 3);
}
其实无非就是:编译器看到你用同样的入参调用了两次 就可以干掉第二次调用。之前的结果被缓存了。(前提是这得是纯函数)。
但是我们前面依赖了全局变量,就不行,它有外部的副作用,返回的结果可能会根据全局变量的不同而不同,没办法缓存。不能保证:多次调用传入的数据相同就能得到完全一致的结果。
3.1 非 const 全局变量的弊端
非 const 的全局变量有许多弊端。首当其冲的弊端是,非 const 的全局变量破坏了封装。这种对封装的破坏让你无法对函数/类(实体)进行独立思考。下面列举非 const 全局变量的主要弊端。
- 可测试性:无法孤立地测试你的实体。如果单元不存在,那么单元测试也将不存在。你只能进行系统测试。实体的执行效果要依赖整个系统状态。
- 重构:因为你无法孤立地对代码进行推理,重构它会相当有挑战。
- 优化:你无法轻易地重新安排函数调用或者在不同的线程上进行函数调用,因为可能有隐藏的依赖。缓存之前函数调用的结果也极为危险。
- 并发:产生数据竞争的必要条件是有共享而可变的状态。而非 const 全局变量正是共享而可变的。
I.3 避免单例
有时,全局变量伪装得很好。
// 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()。而对单例的直接调用有一些严重的后果。你无法对有单例的函数进行单元测试,因为单元不存在。此外,你也不能创建单例的伪对象并在运行期替换,因为单例并不是函数接口的一部分。
我们先聊一下最后一句话:其实就是说,我没办法创造一个和单例一样类型的对象,然后进行函数传参。因为单例不是函数接口的一部分,它通常会被直接调用。 另外,我们要明白,“单例” 它是带状态的,单例的状态是经常变化和难以确定的,因为大家都使用和修改这个单例。如果某个函数使用了这个单例,就没办法对它进行单独的单元测试,因为单例的状态无法确定,单例的状态依赖于所有修改它的代码。这也就是前面说的:你无法对有单例的函数进行单元测试。
- 简而言之,单例破坏了代码的可测试性。
实现单例看似小事一桩,但其实不然。你将面对几个挑战:
- 谁来负责单例的销毁?
- 是否应该允许从单例派生?
- 如何以线程安全的方式初始化单例?
- 当单例互相依赖并属于不同的翻译单元时,应该以何种顺序初始化这些单例?这里要吓唬吓唬你了。这一难题被称为静态初始化顺序问题。
3.2 运用依赖注入化解
当某个对象使用单例的时候,注入的依赖就被注入对象中。而借助依赖注入技术,这个依赖可以变成接口的一部分,并且服务时从外界注入的。这样,客户代码和注入的服务之间就没有依赖了。依赖注入的典型方式是构造函数、设置函数(setter)成员或模板参数。
下面的程序展示了如何使用依赖注入替换一个日志记录器:
#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 纪元以来的时间。
Message
Time since epoch: 1697943951.168827: Message
Time since epoch: 1697943951.171675: Message
3.3 构建良好的接口
- 函数应该通过接口(而不是全局变量)进行沟通。
现在我们来到了本章的核心。按照 C++ Core Guidelines,下面是关于接口的建议。
- 接口明确(I.1)
- 接口精确并具有强类型(I.4)
- 保持较低的参数数目(I.23)
- 避免相同类型却不相关的参数相邻(I.24)
下面的函数 showRectangle 违反了刚提及的接口的所有规则:
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 本应当只显示一个矩形,但修改了它的参数。实质上它有两个目的,因此,它的名字有误导性(I.1)。另外,函数签名没有提供关于参数应该是什么的任何信息,也没有关于应该以什么顺序提供参数的信息(I.23 和 I.24)。此外,参数是没有取值范围约束的双精度浮点数。因此,这种约束必须在函数中确立(I.4)。对比而言,第二个 showRectangle 函数接受两个具体的点对象(Point)。
- 检查 Point 是否合法值是 Point 构造函数的工作。这种检查工作本来就不是函数 showRectangle 的职责。
进一步阐述规则 I.23 和 I.24 以及标准模板库(STL)中的函数 std::transform_reduce
。首先,需要定义属于“可调用”(callable)。 可调用实体是在行为上像函数的东西。它可以是函数,也可以是函数对象,或者是 lambda 表达式。如果可调用实体接受一个参数,它就是一元可调用实体;如果它接受两个参数,则称为二元可调用实体。
std::transform_reduce 先将一元可调用实体应用到一个范围或将二元可调用实体应用在两个范围,然后将二元可调用实体应用到前一步的结果的范围上。当你使用一个一元 lambda 表达式调用 std::transform_reduce 时,这种调用易于正确使用。
std::vector<std::string>strVec{"Only", "for", "testing", "purpose"};
std::size_t res = std::transform_reduce(
std::execution::par,
strVec.begin(), strVec.end(),
0,
[](std::size_t a, std::size_t b) {return a + b; },
[](std::string s) {return s.size(); }
);//res 值为 21。
如果使用 msvc 进行编译,此代码在
VS2022 17.10 Preview
之前无法通过,这是 msvc 的 BUG。 这在于之前的 msvc stl 实现,使用的是{}
进行初始化,因为有窄化转换,所以无法通过编译。这两个 Lambda 表达式都是返回std::size_t
类型的,与0
的int
不同。 不过即使标准允许,也建议不使用0
,而是改成0uz
或std::size_t{0}
,避免隐式转换与警告。
函数 std::transform_reduce
先将每个字符串变换为它的长度 [](std::string s) {return s.size(); }
, 并将二元可调用实体 [](std::size_t a, std::size_t b) {return a + b; },
应用到结果的范围上。求和的初始值是 0。整个计算是并行的 std::execution::par
。
当你使用以下接受两个二元可调用实体的重载版本时,函数声明会变得相当复杂且易错。这违反了 I.23 和 I.24。
template<class ExecutionPolicy,
class ForwardIt1, class ForwardIt2, class T, class BinaryOp1, class BinaryOp2>
T transform_reduce(ExecutionPolicy&& policy,
ForwardIt1 first1, ForwardIt1 last1, ForwardIt2 first2,
T init, BinaryOp1 binary_op1, BinaryOp2 binary_op2);
调用这个重载函数需要 6 个模板参数和 7 个函数参数。按正确顺序使用两个二元可调用实体,可能也是个挑战。
我们展示一下使用这个重载函数的示例代码
std::vector<std::string>strVec{"Only", "for", "testing", "purpose"};
std::vector<std::string>vec{"a", "b", "c", "d"};
std::size_t res = std::transform_reduce(
std::execution::par,
strVec.begin(), strVec.end(),
vec.begin(),
std::size_t{0},
[](std::size_t a, std::size_t b) {return a + b; },
[](std::string s, std::string s2) {return s.size() + s2.size(); }
);
res
结果 是 25。
函数 std::transform_reduce 复杂的原因在于两个函数被合并成了一个。更好的选择应该是分别定义函数 transform 和 reduce,并支持管道运算符调用:transform | reduce。
I.13 不要用单个指针来传递数组
- 不要用单个指针来传递数组。
这是一条非常特殊的规则,肯定会有很多人不屑一顾。这条规则的出现正是为了解决一些未定义行为。例如下面的函数 copy_n 相当容易出错。
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。
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>
消除了。
I.27 为了库 ABI 的文档,考虑使用 PImpl
由于私有数据成员参与类的内存布局,而私有成员函数参与重载决议,对这些实现细节的改动都要求使用了这类的所有用户全部重新编译。而持有指向实现的指针(Pimpl)的 非多态的接口类,则可以将类的用户从其实现的改变隔离开来,而代价是一层间接。
- 接口: 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;
cppreference.com 提供了关于 PImpl 惯用法的更多信息。