[toc]

Designs and Declarations

18. Make interfaces easy to use correctly and hard to use incorrectly

  • 欲开发一个“容易被正确使用,不容易被误用”的接口,首先必须考虑客户可能做出什么样的错误。

类型系统是我们的同盟国

通过一下设计可以避免由于不同国家日期表示方式的区别而产生的错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct Day {            struct Month {                struct Year {
explicit Day(int d) explicit Month(int m) explicit Year(int y)
:val(d) {} :val(m) {} :val(y){}

int val; int val; int val;
}; }; };

class Date {
public:
Date(const Month& m, const Day& d, const Year& y);
...
};
Date d(30, 3, 1995); // error! wrong types

Date d(Day(30), Month(3), Year(1995)); // error! wrong types

Date d(Month(3), Day(30), Year(1995)); // okay, types are correct

这里使用函数代替对象,因为你非局部静态对象(non-local static objects)的初始化的可靠性是值得怀疑的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Month {
public:
static Month Jan() { return Month(1); } // functions returning all valid
static Month Feb() { return Month(2); } // Month values; see below for
... // why these are functions, not
static Month Dec() { return Month(12); } // objects

... // other member functions

private:
explicit Month(int m); // prevent creation of new
// Month values

... // month-specific data
};
Date d(Month::Mar(), Day(30), Year(1995));

书中说enum不具有类型安全性,我试了下msvcg++都会报错,且C++11引入了作用域枚举来避免这一问题,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>

enum class Month {
January,
February,
March,
April,
May,
June,
July,
August,
September,
October,
November,
December
};

int main() {
Month m = Month::January;

// 使用枚举值时,只能使用合法的枚举成员,确保类型安全性
if (m == Month::January) {
std::cout << "It's January" << std::endl;
} else {
std::cout << "It's not January" << std::endl;
}

return 0;
}

others

  • 为调用者提供行为一致的接口,如每个STL容器都有一个名为size的成员函数
  • 令返回指针的函数强制返回智能指针,解决指针使用问题

  • cross-DLL problem一个对象在一个动态链接库(dynamically linked library (DLL))中通过 new 被创建,在另一个不同的 DLL 中被删除。

  • 由于2

请记住

  • 好的接口易于正确使用,而难以错误使用。你应该在你的所有接口中为这个特性努力。
  • 使易于正确使用的方法包括在接口和行为兼容性上与内建类型保持一致。
  • 预防错误的方法包括创建新的类型,限定类型的操作,约束对象的值,以及消除客户的资源管理职责。
  • std::shared_ptr 支持自定义deleter。这可以防止cross-DLL 问题,能用于自动解锁互斥锁等。

Treat class design as type design

  • 你的新类型的对象应该如何创建和销毁?如何做这些将影响到你的类的构造函数和析构函数,以及内存分配和回收的函数(operator new,operator new[],operator delete,和 operator delete[] ——参见 Chapter 8)的设计,除非你不写它们。
  • 对象的初始化和对象的赋值应该有什么不同?这个问题的答案决定了你的构造函数和你的赋值运算符的行为和它们之间的不同。这对于不混淆初始化和赋值是很重要的,因为它们相当于不同的函数调用(参见 Item 4)。
  • 以值传递(passed by value)对于你的新类型的对象意味着什么?记住,拷贝构造函数定义了一个新类型的传值(pass-by-value)如何实现。
  • 你的新类型的合法值的限定条件是什么?通常,对于一个类的数据成员来说,仅有某些值的组合是合法的。那些组合决定了你的类必须维持的不变量。这些不变量决定了你必须在成员函数内部进行错误检查,特别是你的构造函数,赋值运算符,以及 “setter” 函数。它可能也会影响你的函数抛出的异常,以及你的函数的异常规范(exception specification)(你用到它的可能性很小)。
  • 你的新类型是否适合放进一个继承图表中?如果你从已经存在的类继承,你将被那些类的设计所约束,特别是它们的函数是virtual还是 non-virtual(参见 Item 34 和 36)。如果你希望允许其他类继承你的类,将影响到你是否将函数声明为 virtual,特别是你的析构函数(参见 Item 7)。
  • 你的新类型允许哪种类型转换?你的类型身处其它类型的海洋中,所以是否要在你的类型和其它类型之间有一些转换?如果你希望允许 T1 类型的对象隐式转型为 T2 类型的对象,你就要么在 T1 类中写一个类型转换函数(例如,operator T2),要么在 T2 类中写一个非显式的构造函数,而且它们都要能够以单一参数调用。如果你希望仅仅允许显示转换,你就要写执行这个转换的函数,而且你还需要避免使它们的类型转换运算符或非显式构造函数能够以一个参数调用。(作为一个既允许隐式转换又允许显式转换的例子,参见 Item 15。)
  • 对于新类型哪些运算符和函数有意义?这个问题的答案决定你应该为你的类声明哪些函数。其中一些是成员函数,另一些不是(参见 Item 23、24 和 46)。
  • 哪些标准函数不应该被接受?你需要将那些都声明为 private(参见 Item 6)。
  • 你的新类型中哪些成员可以被访问?这个问题的可以帮助你决定哪些成员是 public,哪些是 protected,以及哪些是 private。它也可以帮助你决定哪些类和函数应该是友元,以及一个类嵌套在另一个类内部是否有意义。
  • 什么是你的新类型的 "undeclared interface"?它对于性能考虑,异常安全(exception safety)(参见 Item 29),以及资源使用(例如,锁和动态内存)提供哪种保证?你在这些领域提供的保证将强制影响你的类的实现。
  • 你的新类型有多大程度的通用性?也许你并非真的要定义一个新的类型。也许你要定义一个整个的类型家族。如果是这样,你不需要定义一个新的类,而是需要定义一个新的类模板
  • 一个新的类型真的是你所需要的吗?是否你可以仅仅定义一个新的继承类,以便让你可以为一个已存在的类增加一些功能,也许通过简单地定义一个或更多非成员函数或模板能更好地达成你的目标。

请记住

  • 类设计就是类型设计。定义一个新类型之前,确保考虑了本 Item 讨论的所有问题。

20. prefer pass-by-reference-to-const to pass-by-value

  • 缺省情况下是以pass-by-value传递对象到函数,即除非你额外指定,函数参数都是实际参数的副本,调用端获得函数返回值的副本
  1. pass-by-reference-to-const可以提高运行速度,避免不必要的构造和析构函数
  1. pass-by-reference-to-const
  • 一个纯粹的基类的 Window 对象的显示方法有可能不同于专门的 WindowWithScrollBars对象的显示方法(参见 Item 34 和 36)。
  • 我们想打印窗口,当以(window w)为参数传递,则会构造一个临时的window对象然后打印,若传入对象为WindowWithScrollBars,会造成slicing(对象切割),所有特化信息都会被切除。显然,这不是我们想要的结果。
  • 当以(const Window& w)传入参数,那么传入的是什么类型,传出的就是什么类型。
  • 通常reference用指针表现,pass-by-reference意味着传递的是指针。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Window {
public:
...
std::string name() const; // return name of window
virtual void display() const; // draw window and contents
};

class WindowWithScrollBars: public Window {
public:
...
virtual void display() const;
};

void printNameAndDisplay(Window w) // incorrect! parameter
{ // may be sliced!
std::cout << w.name();
w.display();
}

WindowWithScrollBars wwsb;

printNameAndDisplay(wwsb);


void printNameAndDisplay(const Window& w) // fine, parameter won't
{ // be sliced
std::cout << w.name();
w.display();
}

  • 如果你有个对象属于内置类型(例如int), pass by value 往往比pass by reference 的效率高些。
  • 对象小并不就意味其copy 构造函数不昂贵。许多对象,包括大多数STL 容器内含的东西只比一个指针多一些,但复制这种对象却需承担“复制那些指针所指的每一样东西"。

请记住

  • 尽量用pass-by-reference-to-const替代 pass-by-value。典型情况下它更高效而且可以避免对象切割问题
  • 这条规则并不适用于内建类型及 STL 中的迭代器和函数对象类型。对于它们,pass-by-value通常更合适。

21. Don’t try to return a reference when you must return an object.

  • 任何时候看到一个reference 声明式,你都应该立刻问自己,它的另一个名称是什么?
  • 不要返回pointerreference 指向一个local stack对象,因为返回后该对象即销毁。
  • 不要返回reference指向一个heap-allocated对象:
1
2
3
4
5
6
7
8
9
10
const Rational& operator* (const Rational& lhs, const Rational& rhs)
{
//警告!更糟的写法
Rational* result= new Rational(lhs.n * rhs.n, lhs.d * rhs.d);
return *result;
}

Rational w, x, y, z;
W = x * y * z; //与operator*(operator*(x, y), z) 相同
//在这种情况下,new了两次因此我们需要delete两次对象
  • 返回pointerreference指向一个local static 对象而有可能同时需要多个这样的对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const Rational& operator* (const Rational& lhs,const Rational& rhs)
{
static Rational result;
result =... ;
return result;
}

const Rational& operator== (const Rational& lhs,const Rational& rhs)
{
...
};

Rational a,b,c,d;
if(a*b = c*d) //if(operator==(operator(a,b),operator(c,d))

if语句将永远为真,因为调用端看到的永远是static Rational的现值
  • 因此一个“必须返回新对象”的函数的正确写法是:就让那个函数返回一个新对象呗。相信编译器会让它执行的很快

请记住

绝不要返回pointerreference 指向一个local stack对象,或返回reference指向一个heap-allocated对象,或返回pointerreference指向一个local static 对象而有可能同时需要多个这样的对象。条款4 已经为“在单线程环境中合理返回reference指向一个local static 对象”提供了一份设计实例。

22. Declare data members private.

  • 如果成员变量不是public,那么客户唯一能访问对象的方法就是通过成员函数。
  • 通过成员函数访问private成员变量,可以实现多种访问权限
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class AccessLevels {
public:
...
int getReadOnly() const { return readOnly; }

void setReadWrite(int value) { readWrite = value; }

int getReadWrite() const { return readWrite; }

void setWriteOnly(int value) { writeOnly = value; }

private:
int noAccess; // no access to this int

int readOnly; // read-only access to this int

int readWrite; // read-write access to this int

int writeOnly; // write-only access to this int
};
  • 将成员变量声明为private可以提供封装性。

  • 从封装的角度观之,只有两种访问权限:private(提供封装)protect(不提供封装)

请记住

  • 切记将成员变量声明为private 。这可赋予客户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证,并提供class 作者以充分的实现弹性。
  • protected 并不比public 更具封装性。

23. Prefer non-member non-friend functions to member functions.

  • 封装:如果某些东西被封装,它就不再可见。愈多东西被封装,愈少人可以看到它。而愈少人看到它,我们就有愈大的弹性去变化它,因为我们的改变仅仅直接影响看到改变的那些人事物。这就是我们首先推崇封装的原因:它使我们能够改变事物而只影响有限客户
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class WebBrowser {
public:
...
void clearCache();
void clearHistory();
void removeCookies();
...
};
//很多用户希望能一起执行全部这些动作,所以 WebBrowser 可能也会提供一个函数去这样做:

class WebBrowser {
public:
...
void clearEverything(); // calls clearCache, clearHistory,
// and removeCookies
...
};

//当然,这个功能也能通过非成员函数调用适当的成员函数来提供:
void clearBrowser(WebBrowser& wb)
{
wb.clearCache();
wb.clearHistory();
wb.removeCookies();
}
  • 有点反直觉的是:member 函数clearEverything带来的封装性比non-member 函数clearBrowser 低。因为member函数能访问到类的private变量。
  • 提供non-member non-friend函数可允许对WebBrowser 相关机能有较大的包裹弹性(packaging flexibility), 而那最终导致较低的编译相依度。

在c++,比较常用的做法是让non-member函数放在同一个命名空间

1
2
3
4
5
6
7
8
namespace WebBrowserStuff {

class WebBrowser { ... };

void clearBrowser(WebBrowser& wb);

...
}

namespace 和 class的区别:

from GPT-3.5

  1. Namespace(命名空间)
    • namespace 具有全局范围,可以跨越多个源文件。
    • 可以在不同的源文件中使用 namespace 关键字来定义相同的命名空间,并且这些定义会被合并成一个命名空间
    • 例如,可以在一个头文件中定义命名空间,然后在多个源文件中包含该头文件并使用该命名空间中的名称。
  2. Class(类)
    • class 在单个源文件中定义,并且一般情况下不会跨越多个源文件。
    • 通常情况下,类的定义(包括成员变量和成员函数的实现)位于头文件中,而方法的实现(定义)则位于源文件中
    • 可以在多个源文件中包含相同的类声明(即头文件),但每个源文件中只能有一个类的定义(通常是实现部分)。
  • 通过using namespace std;跳转到定义即可看到标准库是由各个定义于不同头文件的std组成,每个头文件定义了std的某些基能。当包含了对应的头文件,命名空间会合并命名空间。
  • 这样这允许客户只对他们所用的那一小部分系统形成编译相依(见条款31, 其中讨论降低编译依存性的其他做法)

请记住

  • 宁可拿non-member non-friend函数替换member 函数。

24. Declare non-member functions when type conversions should apply to all parameters.

当在类内重载Rational运算符时,我们不能将所有参数都隐式转化为Rational对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Rational {
public:
Rational(int numerator = 0, // ctor is deliberately not explicit;
int denominator = 1); // allows implicit int-to-Rational
// conversions

int numerator() const; // accessors for numerator and
int denominator() const; // denominator — see Item 22

private:
...
};

class Rational {
public:
...

const Rational operator*(const Rational& rhs) const;
};

Rational result = oneHalf * oneEighth; // fine

result = result * oneEighth; // fine

result = oneHalf * 2; // fine 实际:result = oneHalf.operator*(2);

result = 2 * oneHalf; // error! 实际:result = 2.operator*(oneHalf);

因此我们可以使用上一节的知识采用非成员函数去解决这一问题。

1
2
3
4
5
const Rational operator*(const Rational& lhs, const Rational& rhs)    
{
return Rational(lhs.numerator() * rhs.numerator(),
lhs.denominator() * rhs.denominator());
}

请记住

  • 如果你需要为某个函数的所有参数(包括被this 指针所指的那个隐喻参数)进行类型转换,那么这个函数必须是个non-member

25. Consider support for a non-throwing swap.

  • swap函数是异常安全性编程的脊梁

swap的典型实现

需要类型T支持拷贝构造函数copy assignment操作符

1
2
3
4
5
6
7
8
9
10
namespace std {

template<typename T> // typical implementation of std::swap;
void swap(T& a, T& b) // swaps a's and b's values
{
T temp(a);
a = b;
b = temp;
}
}

使用pimpl手法

这样我们只需要交换两个对象的指针即可实现swap

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class WidgetImpl {                          // class for Widget data;
public: // details are unimportant
...

private:
int a, b, c; // possibly lots of data —
std::vector<double> v; // expensive to copy!
...
};

class Widget { // class using the pimpl idiom
public:
Widget(const Widget& rhs);

Widget& operator=(const Widget& rhs) // to copy a Widget, copy its
{ // WidgetImpl object. For
... // details on implementing
*pImpl = *(rhs.pImpl); // operator= in general,
... // see Items 10, 11, and 12.
}
...

private:
WidgetImpl *pImpl; // ptr to object with this
};

将std::swap针对Widget特化

通常我们不能修改std命名空间内的任何东西,但是我们可以为标准templates制造特化版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Widget {                     // same as above, except for the
public: // addition of the swap mem func
...
void swap(Widget& other)
{
using std::swap; // the need for this declaration
// is explained later in this Item

swap(pImpl, other.pImpl); // to swap Widgets, swap their
} // pImpl pointers
...
};

namespace std {

template<> // revised specialization of
void swap<Widget>(Widget& a, // std::swap
Widget& b)
{
a.swap(b); // to swap Widgets, call their
} // swap member function
}

这种做法不只能够通过编译,还与STL 容器有一致性,因为所有STL 容器也都提供有public swap 成员函数std::swap 特化版本(用以调用前者)。

通过non-member swap调用member swap

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
namespace WidgetStuff {
... // templatized WidgetImpl, etc.

template<typename T> // as before, including the swap
class Widget { ... }; // member function

...

template<typename T> // non-member swap function;
void swap(Widget<T>& a, // not part of the std namespace
Widget<T>& b)
{
a.swap(b);
}
}

C++的名称查找法则(name lookup rules; 更具体地说是所谓argument-dependent lookup 或Koenig lookup 法则)会找到WidgetStuff 内的Widget 专属版本。

选择调用合适的swap函数

如果 T 专用版本存在,你希望调用它,如果它不存在,就回过头来调用 std 中的通用版本。

1
2
3
4
5
6
7
8
template<typename T>
void doSomething(T& obj1, T& obj2)
{
using std::swap; // make std::swap available in this function
...
swap(obj1, obj2); // call the best swap for objects of type T
...
}

注意swap函数不能这样调用这将强制编译器只考虑std 中的 swap(包括任何模板特化),因此排除了定义在别处的更为适用的 T 专用版本被调用的可能性。这也就是为你的类完全地特化 std::swap很重要的原因:它使得以这种被误导的方式写出的代码可以用到类型专用的 swap 实现。

1
std::swap(obj1, obj2); // the wrong way to call swap

swap总结

首先,如果swap 的缺省实现码对你的class 或class template 提供可接受的效率,你不需要额外做任何事。任何尝试置换(swap) 那种对象的人都会取得缺省版本,而那将有良好的运作。
其次,如果swap 缺省实现版的效率不足(那几乎总是意味你的class 或template使用了某种pimpl 手法),试着做以下事情:

  1. 提供一个public swap 成员函数,让它高效地置换你的类型的两个对象值,这个函数绝不该抛出异常。
  2. 在你的classtemplate所在的命名空间内提供一个non-member swap, 并令它调用上述swap 成员函数。
  3. 如果你正编写一个class (而非class template) ,为你的class特化std::swap。并令它调用你的swap 成员函数

最后,如果你调用swap, 请确定包含一个using 声明式,以便让std: :swap 在你的函数内曝光可见,然后不加任何namespace 修饰符,赤裸裸地调用swap

请记住

  • 当std:: swap 对你的类型效率不高时,提供一个swap 成员函数,并确定这个函数不抛出异常。
  • 如果你提供一个member swap, 也该提供一个non-member swap 用来调用前者。对于classes (而非templates) ,也请特化std: :swap 。
  • 调用swap 时应针对std:: swap 使用using 声明式,然后调用swap 并且不带任何“命
    名空间资格修饰”。
  • 为“用户定义类型”进行std templates 全特化是好的,但千万不要尝试在std 内加入某些对std 而言全新的东西。