四. 设计与声明
18. 让接口容易被正确使用,不易被误用
请记住:
- 好的接口很容易被正确使用,不容易被误用。你应该在你的所有接口中努力达成这些性质
- "促进正确使用"的办法包括接口的一致性,以及与内置类型的行为兼容。
- "阻止误用"的办法包括建立新类型、限制类型上的操作、束缚对象值,以及消除客户的资源管理责任。
- trl::shared_ptr支持定制型删除器。这可防范DLL问题,可被用来自动解除互斥锁等等。
解释 :
设计优秀的接口确实要注重以下几个方面:
-
易于正确使用:一个良好设计的接口应该使用户能够更容易地使用它。这需要避免在接口设计中的歧义和不一致,并尽量与已有的、用户熟悉的模式保持一致。例如,对一致性和对内置类型行为的兼容都属于这种情况。
-
不容易被误用:我们也应确保接口能够防止用户误用。创建新的类型(以区分不同的概念或值),限制类型上的操作,约束对象的值,或者管理客户端的资源,都是有效的防止误用手段。
std::shared_ptr
的删除器就是一个很好的例子。这个特性让我们能够自定义对象被删除时的行为。例如,当 std::shared_ptr
管理的资源是一个在动态链接库(DLL)中分配的对象,或者是一个需要在释放之前执行特定操作(例如解锁)的资源时,删除器就非常有用了。
以下是一个 std::shared_ptr
如何使用自定义删除器的例子:
C
// 假设 lock 是一个互斥锁对象
std::shared_ptr<std::mutex> lock(new std::mutex, [](std::mutex* m){
m->unlock(); // 解锁
delete m;
});
这里,我们创建了一个 std::shared_ptr
来管理一个 std::mutex
对象,并提供了一个自定义的删除器。当 std::shared_ptr
需要释放它管理的对象时,它就会先调用 unlock
方法,然后再删除对象。这样,我们就不需要关心何时解锁或删除互斥锁对象, std::shared_ptr
会帮我们自动完成。
19. 设计class犹如设计type
请记住:
- 新type的对象应该如何被创建和销毁?
- 对象的初始化和对象的赋值该有什么样的差别?
- 新type的对象如果被passed by value(以值传递),意味着什么?
- 什么是新type的"合法值"?
- 你的新type需要配合某个继承图系吗?
- 你的新type需要什么样的转换?
- 什么样的操作符和函数对此新type而言是合理的?
- 什么样的标准函数应该驳回?
- 该取用新type的成员?
- 什么是新type的"未声明接口"?
- 你的新type有多么一般化?你真的需要一个新type么?
解释:
-
新type的对象应该如何被创建和销毁?:这一问题涉及到类的构造函数和析构函数的设计。构造函数决定如何初始化一个对象,析构函数决定如何清理它。
-
对象的初始化和对象的赋值该有什么样的差别?: 初始化涉及创建新对象时赋予其初始值,而赋值则是将已存在对象的值改变为新的值。这两者的处理可能会有所不同,因此我们通常需要对这两种操作进行清晰的定义。
-
新type的对象如果被passed by value(以值传递),意味着什么?:如果类对象被以值传递,那么会创建该对象的一个复制品。为此,我们需要定义复制构造器以指定如何进行复制。
-
什么是新type的"合法值"?:对于某个特定的类,其对象的"合法值"可能会受到某些约束。"合法值"的概念涉及到类的数据验证和封装。
-
你的新type需要配合某个继承图系吗?:如果新的类型是某个已有类型的特化,或者需要被其他类型进行扩展,那么你需要考虑使用继承。
-
你的新type需要什么样的转换?:在某些情况下,你可能需要为类定义转换运算符,例如转换为其他类类型或基本数据类型。
-
什么样的操作符和函数对此新type而言是合理的?:你需要定义对象可以进行哪些操作,这通常通过重载运算符和定义成员函数来实现。
-
什么样的标准函数应该驳回?:有些情况下,你可能想禁止某些操作,如禁止复制或者赋值等,可以通过将这些函数设为私有并不提供实现来达到这个目的。
-
该取用新type的成员?:注意保持类的封装性,尽可能通过公有成员函数获取和设定私有成员变量,而不是将成员变量设置为公有。
-
什么是新type的"未声明接口"?:这个可能指的是那些未直接列出但通过类的公有接口可以的操作。这种操作需要被考虑在内和进行测试。
-
你的新type有多么一般化?你真的需要一个新type么?:在你决定创建新的类时,需要考虑它是否过于特定或者过于一般化,以及是否真的需要一个新的类。如果对于问题的解决并没有太大帮助,可能需要重新考虑设计。
20. 宁以pass-by-reference-to-const替换pass-by-value
请记住:
- 尽量以pass-by-reference-to-const替换pass-by-value。前者通常比较高级,并可避免切割问题。
- 以上规则并不适用于内置类型,以及STL的迭代器和函数对象。对他们而言,pass-by-value往往比较适当。
解释:
-
尽量以pass-by-reference-to-const替换pass-by-value : 对象在传递过程中,pass-by-value需要对对象进行复制操作,产生新的对象。这通常会发生在函数参数传递和返回值中。但复制大型对象可能会非常耗时,也可能引发性能问题。为了避免这些问题,一种常见的解决方法是使用"传递常量引用",即pass-by-reference-to-const,这样就可以避免复制操作。同时使用const可以避免在函数内部修改原对象。
-
以上规则并不适用于内置类型,以及STL的迭代器和函数对象: 对于内置类型(如int、char等),以及STL的迭代器和函数对象,他们通常在内存占用和复制成本上非常小,因此使用pass-by-value通常会更高效。此外,这些类型通常设计为值语义,使用pass-by-value可以更符合其设计原则。
21. 必须返回对象时,别妄想返回其reference
请记住:
绝不要返回pointer或reference指向一个local stack对象,或返回reference指向一个heap-allocated对象,或返回pointer或reference指向一个local static对象而有可能同时需要多个这样的对象。
解释:
-
绝不要返回pointer或reference指向一个local stack对象:这是因为当函数执行完毕后,它的栈内存会被销毁,那些局部变量也就不复存在了。如果你返回一个指向局部变量的指针或引用,那么该指针或引用就会变成悬挂指针或者悬挂引用,这种无效的引用可能会导致程序错误。
-
或返回reference指向一个heap-allocated对象:这主要是因为在返回引用到堆上分配的对象时,对象的生命周期控制可能变得复杂。如果在函数中分配了堆内存,但是没有正确返回该内存的指针,那么调用者可能根本不知道应该释放这个内存,这就导致了内存泄漏。
-
或返回pointer或reference指向一个local static对象而有可能同时需要多个这样的对象:如果你返回一个指向局部静态变量的指针或引用,而在不同的上下文中需要多个这样的对象,那么这些上下文会共享该对象,这可能会导致出现意料之外的副作用。
总的来说,编程时很重要的一点就是管理好对象的生命周期,不正确的内存管理,如上述的几种情况,可能会引发很多问题。因此在编程时,我们要尽量避免这些错误的用法。
22. 将成员变量声明为private
请记住:
- 切记将成员变量声明为private。这可赋予客户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证,并提供class作者以充分的实现弹性。
- protected并不比public更具封装性。
解释 :
在面向对象编程中,封装是非常重要的特性之一。
-
将成员变量声明为private:这样做能保护类的内部状态,防止客户端代码直接修改它。我们只能通过定义的public方法来访问和修改,这样可以确保数据的安全性和一致性。
-
可细微划分访问控制:private限定符可以使类的成员只能被该类的方法访问,这样我们可以更精细地控制谁可以访问和修改类的状态。
-
约束条件获得保证:通过private属性和公开的setter方法,我们可以在修改数据前执行检查,保证数据满足一定的约束条件。
-
提供class作者以充分的实现弹性:因为客户端代码不能直接访问私有成员,所以我们在未来需要修改类的内部实现时会更加灵活,不需要担心会影响到已有的客户端代码。
-
protected并不比public更具封装性:protected成员可以被自身和任何子类访问,相比private,其访问权限更宽松,所以有时可能不如private符合封装性的理念。
所以,将数据成员设置为private并通过public方法进行访问和修改,是实现良好封装的常用手段。
23. 宁以non-member、non-friend替换member函数
请记住:
宁可拿non-member non-friend函数替换member函数。这样做可以增加封装性、包裹弹性和机能扩充性。
解释:
-
增加封装性:在一些情况下,使用non-member non-friend函数(非成员非友元函数)可以增加类的封装性。这是因为non-member non-friend函数无法访问类的私有和受保护成员,所以对类的内部结构知之甚少。这就使得类的实现可以在不破坏这些函数正确性的情况下自由改变。
-
提高包裹弹性:如果我们知道函数不需要访问对象的私有或受保护成员,那么就没有必要将它作为类的成员函数,这就提供了更多的弹性。我们可以在不改变类定义的情况下添加更多的函数,或者将这些函数放入不同的命名空间中。
-
提升机能扩充性:non-member non-friend函数可以对多个对象执行操作,即使这些对象来自不同的类。相比之下,成员函数只能对它所属的对象执行操作。所以使用非成员非友元函数更加灵活,能更好地扩展功能。
24. 若所有参数皆需类型转换,请为此采用non-member函数
请记住 :
如果你需要为某个函数的所有参数(包括被this指针所指的那个隐喻参数)进行类型转换,那么这个函数必须是个non-member。
解释 :
一个成员函数的隐式this
参数只能用来转换它所属的对象,而不能用来转换传给该函数的其他参数。
但是,非成员函数没有这样的限制,它们可以自由地转换传递给它们的所有参数。因此,如果一个操作需要对所有参数进行类型转换(包括那个由this
指针隐含的参数),那么这个操作通常应该由非成员函数来完成。
请注意,根据C++的运算符重载规则,有两个参数的运算符(例如+或-)应该作为非成员函数来实现,以便能处理左操作数进行的类型转换。然而,有些运算符(例如=或+=)则常常作为成员函数,因为它们通常需要改变它们的左操作数,即this
对象。这是由于它们通常需要直接访问对象的内部状态,而这正是成员函数所提供的。
25. 考虑写一个不抛异常的swap函数
请记住:
- 当std::swap对你的类型效率不高时,提供一个swap成员函数,并确定这个函数不抛出异常。
- 如果你提供一个member swap,也该提供一个non-member swap用来调用前者。对于class(而非templates),也请特化std::swap。
- 调用swap时应针对std::swap使用using声明式,然后调用swap并且不带任何"命名空间资格修饰"。
- 为"用户定义类型"进行std templates全特化是好的,但千万不要尝试在std内加入某些对std而言全新的东西。
解释:
-
提供一个swap成员函数:这样可以确保swap操作针对你的类型最为高效。确保这个函数不会抛出异常,这样可以使之在异常敏感的上下文环境中更安全。
-
提供一个non-member swap:非成员swap函数往往更易用,因为它们可以被引入到不需要访问类内部数据的函数或者范畴中。这个非成员函数应该简单地调用上面定义的成员swap函数。
-
特化std::swap :如果你的类型不适用于标准库提供的
std::swap
,你可以为你的类型提供一个std::swap
的全特化版本,这样可以使标准算法和容器能够利用你的高效swap实现。 -
不带任何"命名空间资格修饰"调用swap:这样可以确保在swap操作符重载的上下文中你总是调用了正确的swap版本。
-
不要在std内添加新东西 :这是一个关于C++编程习惯的通常建议。尽管为
std::swap
提供全特化版本是可以接受的,但在std
命名空间内添加全新的内容是不被允许的,因为这可能引发未定义的行为。