Effective C++ 04 设计与声明

Effective C++ 04 设计与声明,第1张

4. 设计与声明 条款 18:让接口容易被正确使用,不易被误用

欲开发一个“容易被正确使用,不易被误用”的接口,首先必须考虑客户可能做出什么样的错误。参考下面这个表现日期的 class:

class Date {
public:
	Date(int month, int day, int year);
};

这个接口虽然看似没有问题,但是客户可能会穿刺次序错误的参数或传递一个无效的月份或天数:

Date d(30, 3, 1995);  // 应该是 3, 30
Date d(2, 30, 1995);  // 应该是 3, 30
导入新类型

许多客户端错误可以引导如新类型而获得预防,我们可以导入简单的外覆类型来区别天数、月份和年份,然后在用于 Date 构造函数:

// 日
struct Day {
	explicit Day(int d) : val(d) { }
	int val;
};
// 月
struct Month {
	explicit Day(int m) : val(m) { }
	int val;
};
// 年
struct Year {
	explicit Year(int y) : val(y) { }
	int val;
};
// Date
class Date {
public:
	Date(const Month& m, const Day& d, const Year& y);
};
Date d(Month(3), Day(30), Year(1995));  // 正确

现在也可以对其值进行一些限定,例如一年只有 12 个月,我们可以通过利用 enum 来表现月末,也可能通过以函数替换对象的方式来解决。

限制类型上的 *** 作

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

除非有好的理由,负责应该尽量令你的 types 的行为与内置 types 一致。避免无端与内置类型不兼容,正是为了提供行为一致的接口。STL容器的接口十分一致(虽然不是完美一致),这使得它们非常容易被使用。

设计接口尽量减少要求客户实现某事

任何借口如果要求客户必须记得做某些事情,就是有着“不正确使用”的倾向,因为客户可能会忘记做那件事。例如,条款 13 导入了一个 factory 函数,它返回一个指针指向 Investment 继承体系内的一个动态分配对象:

Investment* creatInvestment();

为了避免资源泄露,creatInvestment 返回的指针必须被删除,但这也至少产生了两个可能发生错误的机会:没有删除指针,或删除同一个指针超过一次。在条款 13 中,将 creatInvestment 的返回值存储在智能指针中,因而将 delete 的责任推给指针。为了防止客户忘记使用智能指针,较佳的接口设计原则是先发制人,直接令 factory 函数返回一个智能指针:

std::tr1::shared_ptr creatInvestemnt();

tr1::shared_ptr 有一个特别好的性质是:它会自动使用它的“每个指针专属的删除器”,因而消除另一个潜在的客户错误:所谓的 “cross-DLL probllem”。这个问题发生于“对象在动态连接程序库(DLL)中被 new 创建,却在另一个 DLL 内被 delete 销毁”。在许多平台上,这一类“跨 DLL 的 new/delete 成对运用”会导致运行期错误。tr1::shared_ptr 没有这个问题,因为它缺省的删除器来自“tr1::shared_ptr 诞生所在的那个 DLL”的 delete。

请记住:

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

条款 19:设计 class 犹如设计 type

C++ 就像在其他 OPP 语言一样,当你定义一个新 class,也就定义了一个新 type。这意味着你并不只是 class 设计者,还是 type 设计者。

那么,如何设计高效的 class 呢?首先必须了解你面对的问题。机会每一个 class 都要求你面对下面的提问,而你的回答往往导致你的设计规范:

  • 新 type 的对象应该如何被创建和销毁?

这会影响到你的 class 的构造函数和析构函数以及内存分配函数和释放函数(new 和 delete)的设计。

  • 对象的初始化和对象的赋值有什么样的差别?

这个答案决定你的构造函数和赋值运算符的行为,以及之间的差别。不要混淆“初始化“和”赋值“。

  • 新 type 的对象如果被 passed by value,意味着什么?

拷贝构造函数用来定义个一个 type 的 pass-by-value 怎么实现。

  • 什么是新 type 的“合法值”?

对 class 的成员变量而言,通常只有某些数值集是有效的。那些数值集决定了你爹 class 必须维护的约束条件,也就决定了你的成员函数必须进行的错误检查工作。它也影响函数抛出的异常。

  • 你的新 type 需要配合某个继承图系吗?

如果你继承自某些已有的 class,你就受到那些 class 的设计的舒服,特别是受到它们的函数是 virtual 或 non-virtual 的影响(见条款 34 和 36)。如果你允许其他 class 继承你的 class,那么会影响你所声明的函数,尤其是析构函数是否为 virutal。

  • 你的新 type 需要什么样的转换?

如果你希望允许 T1 类型隐式转换为 T2 类型,就必须在 class T1 内写一个类型转换函数火灾 class T2 内写一个可被单一实参调用的构造函数。

  • 什么样的 *** 作符和函数对此新 type 而言是合理的?

这个问题的答案决定你将为你的 class 声明哪些函数。其中那些是成员函数,哪些不是。

  • 什么样的标准函数应该驳回?

那些被你必须声明为 private 的。

  • 谁该取用新 type 的成员?

这个问题可以帮你决定哪个成员为 public,哪个为 protected,哪个为 private。它也帮助你决定哪一个 class 和/或 function 应该是友元。

  • 什么是新 type 的“未声明接口”?

它对效率、异常安全性(见条款 29)以及资源运用(例如多任务锁定和动态内存)提供何种保证?你在这些方面提供的保证将为你的 class 实现代码加上相应的约束条件。

  • 你的新 type 有多么一般化?

或许你起始并非定义一个新 type,而是顶一个一整个 type 家族。果真如此你就不该定义一个新 class,而应该定义一个新 class template。

  • 你真的需要一个新 type 吗?

如果只是i当以新的 derived class 以便为既有的 class 添加技能,那么说不定单纯定义一或多个 non-member 函数或 templates,更能够达到目标。

请记住:

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

条款 20:宁以 pass-by-reference-to-const 替换 pass-by-value

缺省情况下 C++ 以 by value 方式(继承 C)传递对象至函数。除非你另外指定,否则函数参数都是以实际实参的副本为初值,而调用段所获得的也是函数返回值的一个副本。这些副本是由对象的 copy 构造函数产出,这可能使得 pass-by-value 成为昂贵(费时)的 *** 作。

参考下面的例子:

Student plato;
bool platoIsOK = validateStudent(plato);  // 调用函数

当上述函数被调用时,参数的传递成本是“一次 Student copy 构造函数调用,加上一次 Student 析构函数调用”。而且 Student 对象内还会有其他的对象。尽管成功实现了客户的希望,但是却花费了很大的代价。当然也可以通过 pass by reference to const 的方式来回避这些构造和析构动作:

bool validateStudent(const Student& s);

这种传递方式效率高得多,没有任何构造函数或析构函数被调用,因为没有任何新对象被创建。修订后这个参数声明中的 const 是重要的,因为不这样做的话调用者可能会改变它们传入的哪个 Student 对象。

避免对象切割

通过引用方式传递参数也可以避免 slicing(对象切割)问题。当一个 derived class 对象以 by value 方式传递并被视为一个 base class 对象,base class 的 copy 构造函数会被调用,而派生类的特化性质全被切割掉了,仅仅留下一个基类对象所拥有的性质。

参考下面的例子:

class Window {
public:
	std::string name() const;
	virtual void display() const;
};
class WindowWithScrollBars : public Window {
	virtual void display() const;
};

需要注意的是 display 是个 virtual 函数,这意味着基类对象和派生类对象的显示方式是不同的(见条款 34 和 36)。假设你希望写个函数打印窗口名称,然后显式该窗口,下面是个错误的示范:

void printNameAndDisplay(Window w) {  // 错误,参数可能被切割
	std::cout << w.name();
	w.display();
}

当调用上述函数并交给它一个 WindowWithScrollBars 对象时,参数 w 会被构造成一个 Window,并且丢弃 WindowWithScrollBars 对象所有特化信息,而二者的 display 是不一样的。

解决切割问题的办法就是,以 by reference-to-const 的方式传递给 w:

void printNameAndDisplay(const Window& w) {  // 正确,参数不会被切割
	std::cout << w.name();
	w.display();
}
内置类型的传递

如果窥视 C++ 编译器的底层,你会发现,reference 往往以指针实现出来,因此 pass by reference 通常意味真正传递的是指针。因此如果你有个对象属于内置类型,pass by value 往往比 pass by reference 的效率高些。这个忠告也适用于 STL 的迭代器和函数对象,因为习惯上它们都被设计为 passed by value。迭代器和函数对象的实践者有责任看看它们是否高效且不受切割问题的影响。这取决于你使用哪一部分 C++(见条款 1)。

一般而言可以理解 pass by value 的唯一对象就是内置类型和 STL 的迭代器和函数对象,至于其他任何东西,尽量使用 pass-by-reference-to-const。

请记住:

  • 尽量以 pass-by-reference-to-const 替换 pass-by-value。前者通常比较高效,并可以避免切个问题。
  • 以上规则并不适用于内置类型,以及 STL 的迭代器和函数对象。对它们而言,pass-by-value 往往比较适当。

条款 21:返回对象时,别妄想返回其 reference

虽然值传递会影响效率,但是千万不能在所有传递上都是用引用传递,因为这样可能会传递一些并不存在的对象。

参考下面的例子:

class Rational {
public:
	Rational(int numerator = 0, int demominator = 1);  // 条款 24 说明为什么这个构造函数不声明为 explicit
priate:
	int n, d;
	friend const Rational operator* (const Rational& lhs, const Ration& rhs);  // 条款 3 说明为什么返回类型是 const
};

这个版本的 operator* 是以值传递方式返回其计算结果的。如果将其改为引用传递,则其一定指向某个已存在的 Rational 对象,内含两个 Rational 对象的乘积。我们不可能期望这样一个(内涵乘积的)Rational 对象在调用 operator* 之前就存在:

Rational a(1, 2);
Rational b(3, 5);
Rational c = a * b;

期望原本就存在一个其值为 3/10 的 Rational 对象并不合理。如果 operator* 返回一个引用指向的数值,它必须自己创建那个 Rational 对象。函数创建新对象的途径有两个:在 stack 空间或在 和 heap 空间。

// stack
const Rational& operator* (const Rational& lhs, const Ration& rhs) {
	Rational result(lhs.h * rhs.n, lhs.d * rhs.d);
	return result;
}
// heap
const Rational& operator* (const Rational& lhs, const Ration& rhs) {
	Rational* result = Rational(lhs.h * rhs.n, lhs.d * rhs.d);
	return *result;
}

这两种都是很糟糕的写法,不仅调用了构造函数和析构函数,而且一个返回了一个 local 的引用,一个造成了内存泄露。

对于一个必须返回新对象的函数的正确写法是:就让那个函数返回一个新对象:

inline const Rational operator* (const Rational& lhs, const Ration& rhs) {
	return Rational(lhs.h * rhs.n, lhs.d * rhs.d);
}

当然,你需要承受 operator* 返回值的构造成本和析构成本,然而这只是为了获得正确行为而付出的小代价。可以将以上讨论总结为:当你必须在返回一个 reference 和 返回一个 object 之间抉择时,你的功过就是跳出行为正确的那个。

请记住:

  • 绝对不要返回 pointer 或 reference 指向一个 local stack 对象;绝对不要返回 reference 指向一个 heap-allocated 对象;绝对不要返回 pointer 或 reference 指向一个 local static 对象。

条款 22:将成员变量声明为 private

如果成员变量不是 public,客户唯一能够访问对象的办法就是通过成员函数。如果 public 接口内的每样东西都是函数,客户就不需要再打算访问 class 成员时迷惑地试着记住是否该使用小括号。

如果成员变量是 public,每个人都可以读写它,但如果以函数取得或设定其值,就可以实现出不准访问、只读访问以及读写访问,甚至是只写访问。

如果你通过成员函数访问成员变量,日后可以用其他的方式来替换这个成员变量,而 class 的客户一点也不会知道 class 内部实现已经发生了变化。将成员变量隐藏在函数接口的背后,可以为所有可能的实现提供d性。

如果你对客户隐藏成员变量(也就是封装它们),你可以确保 class 的约束条件总是会获得维护,因为只有成员函数可以影响它们。你也保留了日后变更实现的权力。如果你不隐藏它们,改变任何 public 事物的能力会受到束缚,因为那会破坏太多客户代码。protected 成员变量的论点十分类似,事实上 protected 成员变量的封装性并没有高过 public 成员变量。

请记住:

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

条款 23:宁以 non-member、non-friend 替换 member 函数

参考下面的例子:

class WebBrowser {
public:
	void clearCache();
	void clearHistory();
	void removeCookies();
	void clearEverything();  // 调用上面三个函数
};

clearEverything 函数的机能也可以由一个 non-member 函数调用适当的 member 函数实现:

void clearBrowser(WebBrowser& wb) {
	wb.clearCache();
	wb.clearHistory();
	wb.removeCookies();
}

面向对象守则要求数据应该京可能被封住,然而与直观相反地,member 函数 clearEverything 带来的封装性比 non-member 函数 clearBrowser 低。此外,提供 non-member 函数可允许对 WebBrowser 相关既能有较大的包裹d性,而那最终导致较低的编译相依度,增加 WebBrowser 的可延伸性。

让我们从封装开始讨论。如果某些东西被封装,它就不再课件。越多东西被封装,越少人可以看到它。而越少人看到它,我们就有越大的d性去改变他,因为我们的改变仅仅直接影响看到改变的那些人。因此越多东西被封装,我们改变那些东西的能力就越大。封装使我们能够改变事物而只影响有限的客户。

现考虑对象内的数据。越少的代码可以看到数据(也就是访问它),越多的数据可被封装,而我们也就越能自由的改变数据对象(如,比那辆的数量、类型等)。越多函数可以访问数据,数据的封装性越低。

如果一个 member 函数 和一个 non-member,non-friend 函数提供相同的机能,那么提供较大封装性的是 non-member,non-friend 函数,因为它并不增加能够访问 private 成员的函数数量。

有两点需要注意:第一,这个论述只适用于 non-member,non-friend 函数,friend 函数对 class private 成员的访问权力和 member 函数相同;第二,成为 class 的 non-member,并不妨碍它是另一个 class 的 member。这对于那些习惯于”所有函数都必须定义在 class 内“的语言(如,Eiffel、Java、C#)的程序员很有好。

C++ 的做法是让 clearBrowser 成为一个 non-member 函数并位于 WebBrowse 所在的同一命名空间内:

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

请记住:

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

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

假设你设计一个 class 用来表现有理数,那么允许整数隐式转换为有理数是合理的,参考下面的例子:

class Rational {
public:
	Rational(int numerator = 0, int denominator = 1);  // 构造函数不是 explicit 的,允许 int-to-Rational 的转换
	int numerator() const;
	int denominator() const;
};

假设实现 Rational 的乘法运算,现研究下将 operator* 设计为 Rational 成员函数的情况:

class Rational {
public:
	const Rational operator* (const Rational &rhs) const;
};

这个设计能够让两个有理数进行相乘,但是当进行混合式运算时就会出错:

// 两个 Rational 运算
Rational oneEighth(1, 8);
Rational oneHalf(1, 2);
Rational result = oneEighth * oneHalf;  // 正确
result = result * oneHalf;  // 正确
// 混合运算
result = oneHalf * 2;  // 正确
result = 2 * oneHalf;  // 错误!

乘法应该满足交换律,但是上述代码确实错误的,但是当我们将对应函数形式重写时:

result = oneHalf.operator*(2);  // 正确 整数 2 进行了隐式类型转换
result = 2.operator*(oneHalf);  // 错误!

这时会发现,oneHalf 是一个内含 operator* 函数的 class 对象,所以编译器调用该函数。然而整数 2 并没有相应的 operator* 成员函数,此时编译器会尝试寻找可以被这也调用的 non-member operator*(在命名空间或全局作用域内):

result = operator*(2, oneHalf);

但本例并不存在这样的函数,因此查找失败。

所以说想要支持混合式算术运算,可行之道就是,让 operator* 成为一个 nonmember 函数:

// 现在是一个 non-member 函数
const Rational operator*(const Rational& lhs, const Ration& rhs) {  
	return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator());
}
Rational oneFourth(1, 4);
Rational rusult;
result = oneFourth * 2;  // 正确
result = 2 * oneFourth;  // 正确

需要注意的是,member 函数的反面是 non-member 函数,而不是 frined 函数。很多 C++ 程序员假设,如果一个“与某 class 相关”的函数不是一个 member,就该是个 friend,这是错误的。

请记住:

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

条款 25:考虑写出一个不抛异常的 swap 函数

swap 原本只是 STL 的一部分,后面成为了异常安全性编程(见条款 29)的核心,还可以用来处理自我赋值的可能性(见条款 11)的一个常见机制。

所谓 swap 两对象值,就是将两对象的值彼此赋予对方。缺省情况下 swap 动作可由标准可提供的 swap 算法完成。

namespace std {
	template
	void swap(T& a, T& b) {
		T temp(a);
		a = b;
		b = temp;
	}
}

只要类型 T 支持 copying,缺省的 swap 实现代码就会帮你置换类型为 T 的对象。

交换指针

指针指向一个对象,对象内含真正数据。这种设计的常见表现形式是“pimpl 手法(见条款 31)”。如果以这种手法设计 Widget class:

class Widget {
public:
	void swap(Widget& ohter) {
		using:: std::swap;
		swap(pImpl, orther.pImpl);  // 若置换 Widget 就置换其 pImpl 指针
	}
};
namespace std {
	template<>
	void swap(Widget& a, Widget& b) {
		a.swap(b);  // 若置换 Widget,调用其 swap 成员函数
	}
}

template<> 表示它是 std::swap 的特例化版本,函数名称之后的 表示这一特例化版本针对的类型。换句话说当一般性的 swap template 施行与 Widget 时,就会调用这个版本。

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

class template

现在假设 Widget 时 class template,此时不能像之前一样进行特例化:

namespace std {
	template
	void swap>(Widget& a, Widget& b) {  // 错误,不合法
		a.swap(b);
	}
}

上述代码是不合法的,我们企图偏特化一个 function template(std::swap),但** C++ 只允许对 class template 偏特化,不能在 function template 偏特化**。这段代码无法通过编译。

当打算偏特化一个 function template 时,通常会简单地为它添加一个重载版本,类似于:

namespace std {
	template
	void swap(Widget& a, Widget& b) {  // 错误,不合法
		a.swap(b);
	}
}

一般而言,重载 function template 没有问题,但是 std 是个特殊的命名空间,其管理规则也比较特殊。客户可以全特化 std 内的 template,但是不可以添加新的 template 到 std 里。C++ 标准禁止我们膨胀在 std 里已经声明好的东西。

解决这一问题的方案就是,我们还是声明一个 non-member swap 让它调用 member swap,但不再将那个 nonmember swap 声明为 std::swap 的特例化版本或重载版本。假设 Widget 的所有相关机能让在命名空间 WidgetStuff 内,则结果如下:

name WidgetStuff {
	...
	template
	class Widget { ... };
	...
	template  // non-member swap 函数,这里不属于 std
	void swap(Widget& a, Widget& b) {
		a.swap(b);
	}
}
// 使用
template
void doSomething(T& obj1, T& obj2) {
	using std::swap;  // 令 std::swap 在此函数内可用
	...
	swap(obj1, obj2);  // 为 T 类型对象调用最佳版本 swap
}

一旦编译器看到对 swap 的调用,它们便查找适当的 swap 调用。C++ 的名称查找法则确保找到 global 作用域或 T 所在命名空间内任何 T 专属的 swap。如果 T 时 Widget 并位域命名空间 WidgetStuff 内,编译器会找出 WidgetStuff 内的 swap。如果没有 T 专属的 swap,编译器就会使用 std 内的 swap。

如果 std::swap 效率不足,试着做以下事情:

  1. 提供一个 public swap 成员函数,让它高效地置换你的类型的两个对象值。
  2. 在你的 class 或 template 所在的命名空间内提供一个 non-member swap,并令他调用上述 swap 函数。
  3. 如果你正编写一个 class(非 class template),为你的 class 特例化 std::swap。并令它调用你的 swap。
  4. 包含一个 using 声明式,以便让 std::swap 在你的函数内曝光,并且不要加任何 namespace 修饰符(如 std::swap)。

请记住:

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

欢迎分享,转载请注明来源:内存溢出

原文地址:https://54852.com/langs/788934.html

(0)
打赏 微信扫一扫微信扫一扫 支付宝扫一扫支付宝扫一扫
上一篇 2022-05-05
下一篇2022-05-05

发表评论

登录后才能评论

评论列表(0条)

    保存