Effective C++ 3nd——设计与声明

Effective C++ 3nd——设计与声明

本章将对良好 C++ 接口的设计和声明发起攻势,“让接口容易被正确使用,不容易被误用”,让其他更专精的准对对付一大范围的题目,包括正确性、高效性、封装性、维护性、延展性、以及协议的一致性

让接口容易被正确使用,不易被误用

理想上,如果客户企图使用某个接口而却没有获得他所预期的行为,这个代码不该通过编译;如果代码通过了编译,它的作为就该是客户想要的

简单说就是为接口添加限制条件,让接口不易被误用;让自己设计的 types 的行为与内置类型一致

预防客户错误的一个办法是,限制类型内什么事可做,什么事不能做。常见的限制是加上 const

避免无端与内置类型不兼容,真正的理由是为了提供行为一致的接口哦。很少有其他性质比得上 “一致性” 更能导致 “接口容易被正确使用” ,也很少有其他性质比得上 “不一致” 更加剧接口的恶化

任何接口如果要求客户必须记得做某些事,就是有着 “不正确使用” 的倾向

请记住:

  • 好的接口很容易被正确使用,不容易被误用。你应该在你的所有接口中努力达成这些性质
  • “促进正确使用” 的办法包括接口的一致性,以及与内置类型的行为兼容
  • “阻止误用” 的办法包括建立新类型、限制类型上的操作,束缚对象值,以及消除客户的资源管理责任
  • tr1::shared_ptr 支持定制型删除器。这可防范 DLL 问题

DDL 问题:所谓 cross-DLL-problem ,这个问题发生于对象在动态连接程序库(DLL)中被 new 创建,却在另一个 DLL 内被 delete 销毁。在许多平台上,这一类 “跨 DLL 之 new/delete 成对运用” 会导致运行期错误

设计 class 犹如设计 type

好的 types 有自然的语法,直观的语义,以及一或多个高效实现品。那么如何设计高效的 class 呢?首先你必须了解你面对的问题。几乎每一个 class 都要求你面对以下提问

  • 新 type 的对象应该如何被创建和销毁: 将会影响你的构造函数和析构函数以及内存分配和释放函数的设计
  • 对象的初始化和对象的赋值该有什么样的差别: 决定你的构造函数和赋值操作符的行为,以及其间的差异
  • 新 type 的对象如果被 pass by value ,意味着什么: 拷贝构造函数用来定义一个 type 的 pass-by-value 该如何实现
  • 什么是新 type 的 “合法值”
  • 你的新 type 需要配合某个继承图系吗: 如果你继承自某些既有的 classes ,你就受到那些 classes 的设计的束缚,特别是受到 “它们的函数是 virtual 或 non-virtual” 的影响
  • 你的新 type 需要什么样的转换: 包括显示转换和隐式转换
  • 什么样的操作符和函数对此新 type 而言是合理的
  • 什么样的标准函数应该驳回: 即设计哪些函数是 private 的
  • 谁该取用新 type 的成员: 设计公有成员、受保护的成员、私有成员以及友元函数或友元类
  • 什么是新 type 的 “未声明接口” : 它对效率、异常安全性以及资源运用提供何种保证
  • 你的新 type 有多么一体化: 或许你其实并非定义一个新 type ,而是定义一整个 types 家族。此时你应该定义一个新的 class template
  • 你真的需要一个新 type 吗

请记住:

  • Class 的设计就是 type 的设计。在定义一个新 type 之前,请确定你已经考虑过本条款覆盖的所有讨论主题

宁以 pass-by-reference-to-const 替换 pass-by-value

当我们以传值的方式传递参数时,调用端获得的往往是该参数的复件。这些复件由对对象的拷贝构造函数生成,可能使得 pass-by-value 称为昂贵的操作

传引用的方式效率就高的多,因为其没有任何构造函数或析构函数被调用,因为没有任何新对象被创建。但往往需要传递常量引用,即达到与传值类似的情况:不会修改实参的值;而且以传引用的方式也可以避免对象切割问题。

对象切割:当一个派生类对象以传值的方式传递并被视为一个基类对象,基类的拷贝构造函数会被调用,而该派生类独有的特性(数据成员)将会被切割,仅仅留下一个基类对象

考虑以下代码:

class Window{
public:
	std::string name() const;  // 获取窗口的名字
	virtual void display() const;  // 显示窗口及其内容
};

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

// 现在我们写一个打印窗口名称,然后显示窗口该窗口的函数
void printNameAndDisplay(Window w){
	std::cout << w.name();
	w.display();
}

Window_derived window;  // 创建一个其派生类的对象
printNameAndDisplay(window);

此时,我们以传值的方式传入一个 Window_derived 对象,但我们是以传值的方式传递。在 printNameAndDisplay 函数内不论传递过来的对象原来是什么类型,参数 w 就像一个 Window 对象(因为其类型是 Window)。因此在函数内调用 display 调用的总是 Window : : display ,不会是我们希望的 Window_derived : : display 。解决办法是使用 by-reference-to-const 的方式传递 w 。

如果窥视 C++ 编译器的底层,你会发现,引用往往以指针实现出来,因此传引用通常真正传递的是指针。因此如果你有个对象属于内置类型,传值往往比传引用更高效些。 这也适用于 STL 的迭代器和函数对象

一般而言,你可以合理假设 “pass-by-value” 并不昂贵的唯一对象就是内置类型和 STL 的迭代器和函数对象。至于其他任何东西都请遵守本条款的忠告,尽量以传引用的方式传递参数

请记住:

  • 尽量以传引用的方式代替传值,前者通常比较高效,并可避免切割问题
  • 以上规则并不适用于内置类型,以及 STL 的迭代器和函数对象。对它们而言,使用传值往往比较适当

必须返回对象时,别妄想返回其 reference

简单来说,就是当函数必须要以传值的方式返回对象时,不要尝试用引用去代替。虽然传引用可以避免调用构造函数的开销,但是在某些情况下也可能会产生错误,比如:

  • 当我们返回函数的局部变量时,不要返回其引用。因为引用本身只是一个别名,没有分配内存。如果以引用的方式返回局部变量,由于局部变量在函数退出之前就销毁,那么返回的引用就会指向一个已经被销毁的对象,会引发未定义的行为
  • 考虑以下代码:
    class Rational {};
    const Rational& operator* (const Rational& lhs, const Rational& rhs){
    	static Rational result;
    	result = lhs * rhs;
    	return result;
    }
    
    bool operator== (const Rational& lhs, const Rational& rhs);
    
    Rational a, b, c, d;
    if( ( a * b ) == ( c * d ) ){}
    else {}
    
    此时 if 里的条件判断总是为 true ,不论 a ,b ,c ,d 的值是什么
    因为在 operator== 调用之前已经有两个 operator* 被调用,每一个 operator* 都返回一个指向 static Rational result 的引用。因此 operator== 比较的是两个相同的引用,或者说这两个引用指向的是同一个对象,所以比较的结果总是为 true

请记住:

  • 绝不要返回指针或引用指向一个 local stack 对象,或返回引用指向一个在堆上分配的对象,或返回指针或引用指向一个 local static 对象而有可能同时需要多个这样的对象

将成员变量声明为 private

其实就是实现类成员的封装性和对客户的可不见性,还可以对相关数据成员添加访问权限

请记住:

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

宁以 non-member 、non-friend 替换 member 函数

面向对象守则要求,数据以及操作数据的函数应该被捆绑在一块,这意味它建议 member 函数是较好的选择。不幸的是这个建议不正确。这是基于对面向对象真实意义的一个误解。面向对象守则要求数据应该尽可能被封装,然而于直观相反地,member 函数带来的封装性比 non-member 函数低。此外,提供 non-member 函数可允许对类的相关机能有较大的包裹弹性,而那最终导致较低的编译相依度,增加类的可延伸性。因此在许多方面 non-member 做法比 member 函数做法好

  • 封装:如果某些东西被封装,它就不再可见。越多东西被封装,越少人可以看到它。而越少人可以看到它,我们就有越大的弹性去变化它,因为我们的改变仅仅直接影响看到改变的那些事物。因此,越多东西被封装,我们改变那些东西的能力也就越大。这就是我们首先推崇封装的原因:它使我们能够改变事物而只影响有限客户
  • 如何量测 “有多少代码可以看到某一块数据” 呢?我们计算能够访问该数据的函数数量,作为一种粗糙的测量。越多函数可访问它,数据的封装性就越低
  • 能够访问 private 成员变量的函数只有 class 的 member 函数加上 friend 函数而已。如果要你在一个 member 函数和一个 non-member 、non-friend 函数之间做抉择,而且两者提供相同机能,那么导致较大封装性的是 non-member non friend 函数,因为它并不增加 “能够访问类内的 private 成分” 的函数数量。这就解释了为什么 non-member 比 member 函数更受欢迎的原因:它有较大的封装性
  • 在这一点上有两件事值得注意:
    • 这个论述只适用于 non-member non-friend 函数。friend 函数对类私有成员的访问权力和 member 函数相同,因此两者对封装的冲击力道也相同。从封装的角度看,这里的选择关键并不在 member 和 non-member 函数之间,而是在 member 和 non-member non-friend 函数之间
    • 只因在意封装性而让函数 “成为类的 non-member” 并不意味着它 “不可以是另一个类的 member” 。我们可以定义一个工具类,在这个工具类里面提供相应的 non-member 函数

考虑以下代码:

class WebBrowser{
public:
	void clearCache();  // 清除缓存
	void clearHistory();  // 清除历史记录
	void clearCookies();  // 清除Cookie
};

// 此时我们想定义一个函数一次性实现上述三个函数的功能,很容易想到可以定义一个函数,在该函数内调用上面三个函数
// 有两种方法:1、定义为 member 函数;2、定义为 non-member 函数
class WebBrowser{
public:
	...
	void clearEvering();  // 该函数会调用上述三个成员函数
	...
};

void clearBrowser(WebBrowser& wb){  // 该函数的功能于 clearEverthing 的功能相同
	wb.clearCache();
	wb.clearHistory();
	wb.clearCookie();
}

根据面向对象守则,使用 clearEverthing(member 函数) 比较好。但是从封装性的角度来说,clearBrowser(non-member 函数)更好。因为 clearEverthing 定义在类内,它可以访问类的私有成员,对类的封装性冲击更大。当函数的功能不断扩充,其封装性就会越差,一旦功能发生变更,改动的地方就会很大

但是 clearBrowser 是绝对不会访问到类的私有成员,因为编译器不允许。你也许会争辩,把这个总清除功能的函数放在类外,就会隔离与类的关联。但是从逻辑上看,这个函数就是类内三个函数的组合,放在类外,降低了类的内聚性

为此,书上提供了一种解决方案:令 clearBrowser 称为一个 non-member 函数并且位于 WebBrowser 所在的同一个命名空间内:

namespace WebBrowserStuff{
	class WebBrowser { ... };
	void clearBrowser(WebBrowser& wb);
}

namespace 和 class 不同,前者可以跨越多个源码文件而后者不能。如果 WebBrowser 类里面有很多类似 clearBrowser 的遍历函数,比如与书签有关、与打印有关,还有一些与cookie的管理有关,我们就可以把它们定义在同一个命名空间类,放在不同的头文件中,例如:

// 头文件 “webbrowser.h” 这个头文件针对 class WebBrowser 自身
// 及 WebBrowser 核心机能
namespace WebBrowserStuff{
	class WebBrowser { ... };
	...  // 核心机能,例如几乎所有客户都需要的 non-member 函数
}

// 头文件 “webbrowserbookmarks.h”
namespace WebBrowserStuff{
	...  // 与书签相关的遍历函数
}

// 头文件 “webbrowsercookies.h“
namespace WebBrowserStuff{
	...  // 与 cookie 相关的遍历函数
}

注意,这正是 C++ 标准程序库的组织方式。通过命名空间的捆绑,是在封装和内聚之间非常好的平衡

请记住:

  • 宁可拿 non-member non-friend 函数替换 member 函数。这样做可以增加封装性、包裹弹性和机能扩充性

若所有参数皆需要类型转换,请为此采用 non-member 函数

令类支持隐式类型转换是个糟糕的注意,但是这条规则也有其例外。最常见的就是在建立数值类型时。假设我们设计一个类:

class Rational{
public:
	// 构造函数声明为 non-explicit 是为了执行隐式类型转换
	Rational (int numerator = 0, int denominator = 1);
	int numerator() const;
	int denominator() const;
private:
	...
};

// 假如我们想让这个类支持类似加法,乘法等算数运算,但我们不知道是用 member 函数还是 non-member 函数
// 先看看 member 函数
class Rational{
public:
	const Rational operator* (const Rational& rhs) const;
};

// 通过这个设计我们可以完成
Rational oneEighth(1, 8);
Rational oneHalf(1, 2);
Ration result = oneHalf * oneEighth;
result= result * oneEighth;

// 但当我们使用混合运算时,就可能产生错误
result = oneHalf * 2;  // 正确		   
result = 2 * oneHalf;  // 错误

// 对上面两个式子重写
result = oneHalf.operator*(2);  // 这里发生了隐式类型转换,2 转换为了 Rational 对象
result = 2.operator*(oneHalf);  // 没有发生隐式类型转换

问题就出来了,第一个式子是合理的,第二个式子因为 2 不是 Rational 类的对象,不包含 operator* 操作,所以会出错。此时,编译器会尝试在命名空间或全局作用域内寻找一个可以实现如下功能的 operator* 的 non-member 函数

result = operator*(2, oneHalf);

但本例并不存在这样一个函数,所以查找失败

那么为什么第二个语句没有发生隐式类型转换:结论是,只有当参数被列于参数列内,这个参数才是隐式类型转换的合格参与者。地位相当于 “被调用的成员函数所隶属的那个对象” —— 即 this 对象 —— 的那个隐喻参数,绝不是隐式转换的合格参与者。这就是为什么第一个可以通过编译而第二个不能,因为第一个调用伴随着一个放在参数列内的参数,第二个调用则没有

当我们让 operator* 成为一个 non-member 函数,便允许编译器在每一个实参身上执行隐式类型转换:

const Rational operator*(const Rational& lhs, const Rational& rhs){
	return Rational(lhs.numerator() * rhs.numerator(),
					lhs.denominator() * rhs.denominator());
}

Rational oneFourth(1, 4);
Rational result;
result = oneFourth * 2;
result = 2 * oneFourth;

此时便不会出错,不过还有一点必须注意:operator * 是否因该成为 Rational class 的一个 friend 函数呢。就本例而言答案是否定的,因为 operator* 完全可以藉由 Rational 的 public 接口完成任务。这导出一个重要的观察:member 函数的反面是 non-member 函数,不是 friend 函数

请记住:

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

考虑写出一个不抛出异常的 swap 函数

swap 是一个有趣的函数。原本它只是 STL 的一部分,而后成为异常安全性变成的脊柱,以及用来处理自我赋值可能性的一个常见机制。由于 swap 函数如此有用,适当的实现很重要。然而在非凡的重要性之外它也带来了非凡的复杂度

本条款主要讨论两件事:

  • 为你的类编写高效的 swap 函数
  • swap 不抛出异常

默认情况下的 swap 函数会涉及到三个对象的复制,但是对某些类型而言,这些复制动作无一必要。其中最主要的就是 “以指针指向一个对象,内含真正数据” 那种类型。这种设计最常见的表现形式是所谓 “pimpl手法”。考虑以下代码:

class WidgetImpl{
public:
	...
private:
	int a, b, c;
	std::vector<double> v;
	...  // 可能有很多数据,意味着复制的开销比较大
};

class Widget{  // 这个类使用 pimpl 手法
public:
	Widget(const Widget& rhs);
	Widget& operator=(const Widget& rhs){
		...
		*pImpl = *(rhs.pImpl);
		...
	}
	...
private:
	WidgetImpl* pImpl;
};

一旦要置换两个 Widget 对象值,我们唯一需要做的就是置换其 pImpl 指针,但默认的 swap 算法不知道这一点。它不只复制三个 Widget ,还复制三个 WidgetImpl 对象。非常缺乏效率

要实现我们希望的的一个做法是:将 std : : swap 针对 Widget 特化。通常我们不能够改变 std 命名空间内的任何东西,但可以为标准 template 制造特化版本,使它专属于我们自己的类

namespace std{
	template < >
	void swap<Widget>(Widget& a, Widget& b){
		swap(a.pImpl, b.pImpl);  // 实际上无法通过编译
								 // 试图访问类中的私有成员
	}
}

// 我们可以在 Widget 类里面声明一个名为 swap 的 public 成员函数来完成这项工作
class Widget{
public:
	void swap(Widget& other){
		using std::swap;
		swap(pImpl, other.pImpl);
	}
};

namespace std{  // 修订后的 std::swap 特化版本
	template < >
	void swap<Widget>(Widget& a, Widget& b){ a.swap(b); }
}

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

注意:C++只允许对 class template 偏特化,在 function template 身上偏特化是行不通的

当你打算偏特化一个函数模板时,惯常的做法是简单地为它添加一个重载版本:

namespace std{  // std::swap 的一个重载版本
	template < typename T >
	void swap(Widget<T>& a, Widget<T>& b) { a.swap(b); }  // 注意,这其实也是不合法的
}

一般而言,重载函数模板没有问题,但 std 是个特殊的命名空间,其管理规则也比较特殊。客户可以全特化 std 内的 template ,但不可以添加新的 template 到 std 里面。既然如此,我们可以声明一个 non-member swap 让它调用 member swap ,但不将那个 non-member swap 声明为 std : : swap 的特化版本或重载版本

namespace WidgetStuff{
	template < typename T >
	class Widget { ... };  // 同前,内含 swap 函数
	
	template < typename T >  // non-member 函数,不属于 std 命名空间
	void swap(Widget<T>& a, Widget<T>& b) { a.swap(b); }
}

现在,任何地点的任何代码如果打算置换两个 Widget 对象,C++ 的名称查找规则会找到 WidgetStuff 内的 Widget 专属版本。如果想让自己的 “class 专属版” swap 在尽可能多的语境下被调用,你需要同时在该 class 所在的命名空间内写一个 non-member 版本以及一个 std : : swap 的特化版本

C++ 的名称查找规则确保找到 global 作用域或 T 所在的命名空间内的任何 T 专属的 swap。编译器会使用 “实参取决的查找规则” 找出 WidgetStuff 内的 swap 。如果没有则会调用 std 版本的 swap,然而编译器偏爱调用特化版本。**所以,令适当的 swap 被调用是很容易的。需要小心的是,别为这一调用添加额外修饰符,会影响 C++ 挑选适当函数

总结:

  • 如果 swap 的默认实现的效率是可接受的,直接使用便可
  • 如果效率不够,就可以:
    • 提供一个 public swap 成员函数,让它高效地置换你的类型的两个对象值,但这个函数绝不该抛出异常
    • 在你的类或模板所在的命名空间内提供一个 non-member swap ,并令它调用上述 swap 成员函数
    • 如果你正在编写一个类,为你的类特化 std : : swap 。并令它调用你的 swap 成员函数
  • 最后,如果你调用 swap ,请确定包含一个 using 声明式,以便让 std : : swap 在你的函数内曝光可见,然后不加任何 namespace 修饰符,赤裸裸地调用 swap

成员版 swap 绝不可抛出异常。因为 swap 的一个最好的应用是帮助类提供强烈的异常安全性保障。但此技术只基于 swap 函数是成员版的。默认的 swap 函数是以拷贝构造函数和拷贝赋值操作符为基础,而一般情况下两者都允许抛出异常。因此当你编写一个自定义的 swap ,往往提供的不只是高效置换对象值的办法,而且不抛出异常。一般来说,这两个特性是连在一起的,因为高效率的 swap 几乎总是基于对内置类型的操作,而内置类型上的操作绝不会抛出异常

请记住:

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

JallinRichel

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值