条款 18:让接口容易被正确使用,不易被误用。
核心原则:接口设计的核心目标是实现「编译期拦截错误,运行期降低隐患」。借助类型系统、语义约束及自动化资源管理机制,将潜在错误在代码编写阶段彻底消除。
一、促进正确使用:保持一致性与直觉化
- 与内置类型行为对齐
-
案例:自定义
Rational
类时,为保证与int
行为一致,需让a * b = c
这一错误写法编译失败,可通过让operator*
返回const
类型实现(此特性在 C++98 中已支持,C++20 进一步强化了该约束)。 -
扩展:C++20 引入的
consteval
关键字能将计算过程转移至编译期,可进一步约束接口误用,例如在编译期检查Rational
类的分母不为零。
- 遵循 STL 式接口一致性
-
反例:Java 中数组的
length()
、字符串的length()
与集合的size()
,这种接口命名的不一致性增加了使用难度。 -
正例:C++ 标准库容器统一采用
size()
获取大小,C++23 相关提案(如std::span
)延续了这一设计理念,大幅降低了开发者的记忆成本。
二、阻止误用:依托类型系统与自动化机制
- 实施强类型约束
-
案例:
Date
类中的Month
成员,通过explicit Month(int m)
结合 C++20 的constexpr
进行编译期检查,将其有效值限定在[1,12]
范围内,以此替代安全性较弱的enum
(C++11 起enum class
在一定程度上解决了类型安全问题,但自定义类的灵活性更高)。 -
C++17 扩展:若
Month
被设计为聚合类型(即没有用户定义的构造函数),需留意 C++17 允许通过继承基类进行聚合初始化,这可能会意外绕过约束。此时,应显式添加=delete
的默认构造函数,或标记为explicit
以避免此类问题。
- 实现资源管理自动化
工厂函数优化:
// C++11方案:绑定自定义删除器,规避跨DLL问题
std::shared_ptr<Investment> createInvestment() {
return {new Stock, getRidOfInvestment}; // 替代原始指针,自动调用删除器
}
- 规范参数顺序与语义标记
案例:Date(int year, int month, int day)的参数顺序易混淆,在 C++20 中,可通过struct Year, Month, Day进行强类型包裹,并配合constexpr构造函数解决:
struct Month {
explicit constexpr Month(int m) : val(m) { if (m < 1 || m > 12) throw std::out_of_range{}; }
int val;
};
Date(Year y, Month m, Day d); // 可在编译期捕获类型不匹配错误
任何接口如果要求客户必须记得做某件事,就是有着“不正确”使用的倾向,因为客户有可能会忘记那件事。
欲开发一个“容易被正确使用,不容易被误用”的接口,首先必须考虑客户可能做出什么样的错误。
任何接口如果要求客户必须记得做某些事情,就有者“不正确使用”的倾向,因为客户可能会忘记做那件事。
请记住:
- 好的接口很容易被正确使用,不容易被误用。你应该在你的所有接口中努力达成这些性质
- “促进正确使用”的办法包括接口的一致性,以及与内置类型的行为的兼容
- “阻止误用”的办法包括建立新类型、限制类型上的操作,束缚对象指,以及消除客户的资源管理责任
- std::shared_ptr支持定制型删除器(custom deleter)。这可防范DLL问题,可悲用来自动解除互斥锁等等
条款 19:设计 class 犹如设计 type。
设计class需要考虑的问题:
- 新type的对象应该如何被创建和销毁?
- 对象的初始化和对象的赋值该有什么样的差别?
- 新type的对象如果被passed by value(以值传递),意味着什么?
- 什么是type的”合法值“?
- 你的新type需要配合某个继承图系(inheritance graph)吗?
- 你的新type需要什么样的转换?
- 什么样的操作符和函数对此新type而言是合理的?
- 什么样的标准函数应该驳回?
- 谁该取用新type的成员?
- 什么是新type的“未声明接口”(undeclared interface)
- 你的type有多么一般化?
- 你真的需要一个新的type吗?
条款 20:宁以 pass - by - reference - to - const 替换 pass - by - value。
核心考量:在 C++ 中,参数传递方式影响性能与对象语义。值传递因对象拷贝构造和析构,对复杂对象开销巨大;引用传递则可规避此问题,尤其是传递const引用,既能防止对象被意外修改,又能保持高效。
C++98 案例:假设自定义Widget类,有复杂的构造与析构过程。若函数void processWidget(Widget w)采用值传递,每次调用都会触发拷贝构造,即便函数内部未修改w。而改为void processWidget(const Widget& w),仅传递引用,不会产生额外对象拷贝,显著提升性能。
C++11 扩展:C++11 引入右值引用和移动语义,在传递临时对象时,若函数接受右值引用(如void processWidget(Widget&& w)),可实现零拷贝移动,进一步优化性能。但多数情况下,const左值引用仍是通用且安全的选择,既能处理左值参数,又能隐式转换临时右值。
当使用pass-by-value时,如果传递子类对象,会发生对象切割(slicing),因为调用的是基类的构造,子类的相应扩展都会被删除。
当传递的对象是内置类型如int时,pass-by-value往往比pass-by-reference效率要高,这条规则同样适用于STL的迭代器和函数对象。
条款 21:必须返回对象时,别妄想返回其 reference。
返回reference是一个糟糕的设计,会导致内存泄露、不符合预期等糟糕场景发生。
C++98 案例:若函数
const Rational& operator*(const Rational& lhs, const Rational& rhs) {
Rational result(lhs.n * rhs.n, lhs.d * rhs.d);
return result;
}
返回局部result的引用,调用者使用时将访问已销毁对象。正确做法是直接return Rational(lhs.n * rhs.n, lhs.d * rhs.d);
,编译器在合适场景下会应用 RVO,消除不必要的拷贝。
C++11 及以后:C++11 的移动语义配合 RVO,在返回对象时能实现高效转移。例如,当返回类型支持移动构造时,编译器可将局部对象直接移动到调用者位置,而非传统拷贝。如
std::vector<int> createVector() {
std::vector<int> v;
v.push_back(1);
return v;
}
编译器会将v直接移动到调用者的std::vector中,减少性能损耗。
请记住:
绝不要返回pointer或reference指向一个local stack对象,或返回reference指向一个heap-allocated对象,或返回pointer或reference指向一个local static对象而有可能同时需要多个这样的对象。条款4已经为"在单线程环境中合理返回reference指向一个local static对象"提供了一份设计实例。
条款 22:将成员变量声明为 private。
可获得语法一致性,不论是成员还是函数都是以小括号的函数进行调用。
可以对成员的读写进行限制。
可以对成员的值范围进行限制。
将成员变量隐藏在函数接口的背后,可以为“所有可能得实现”提供弹性。
请记住:
- 切记将成员变量声明为private。这可赋予客户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证,并提供class作者以充分的实现弹性。
- protected并不比public更具封装性。
条款 23:宁以 non - member、non - friend 函数替换 member 函数。
主要体现的是一种类的函数组织方式,比较优秀的方式是:
// 头文件"webbrowser.h"--这个头文件针对class WebBrowser自身以及WebBrowser核心技能。
namespace WebBrowserStuff {
class WebBrowser {...};
... // 核心机能,例如几乎所有客户都需要的non-member函数
}
// 头文件"webbrowserbookmarks.h"
namespace WebBrowserStuff {
... // 与书签相关的便利函数
}
// 头文件"webbrowsercookies.h"
namespace WebBrowserStuff {
... // 与cookie相关的便利函数
}
...
请记住:
- 宁可拿non-member non-friend函数替代member函数。这样做可以增加封装性、包裹弹性(packaging flexibility)和机能扩充性。
条款 24:若所有参数皆需类型转换,请为此采用 non - member 函数。
举例:
有理数
class Rational {
...
};
const Rational operator*(const Rational &lhs, const Rational &rhs)
{
return Rational(lhs.numerator() * rhs.numerator(), lhs.demominator() * rhs.denominator());
}
Rational oneFourth(1, 4);
Rational result;
result = oneFourth * 2; // 没问题
result = 2 * oneFourth; // Ok
请记住:
如果你需要为某个函数的所有参数(包括被this指针所指的那个隐喻参数)进行类型转换,那么在这个函数必须是个non-member。
条款 25:考虑写出一个不抛出异常的 swap 函数。
请记住:
- 当std::swap对你的类型效率不高时,提供一个swap成员函数,并确定这个函数不抛出异常。
- 如果你提供一个member swap,也该提供一个non-member swap用来调用前者。对于classes(而非templates),也请特化std::swap。
- 调用swap时应针对std::swap使用using声明式,然后调用swap并且不带任何“命名空间资格修饰”。
- 为“用户定义类型”进行std templates全特化是好的,但千万不要尝试在std内加入某些对std而言全新的东西。