04.designs-and-declarations
本文对应原书的第四部分,主要介绍为了设计和声明良好的
item18 让接口不容易被误用
所谓的接口就是提供给用户使用代码的途径。
- 建立新的数据类型、限制类型上的操作 接口要设计的不易被误用,就要充分考虑用户可能犯的错误。假如设计一个表示日期的类:
class Date
{
public:
Date(int month, int day, int year); // 美式日期标准
}
Date d1(30,3,1995); // 错误,输入的是英式日期标准
Date d2(3,40,1995); // 数字输入错误
像上述这样的错误,可以通过引入新的数据类型来进行预防。通过使用简单的包装类
struct Day{
explicit Day(int d):val(d){}
int val;
};
struct Month{
explicit Month(int m):val(m){}
int val;
};
struct Year{
explicit Year(int y):val(y){}
int val;
};
class Date
{
public:
Date(const Month& m, const Day& d, const Year& y); // 美式日期标准
}
Date d1(3,30,1995); // 数据类型错误
Date d2(Day(30), Month(3), Year(1995)); // 格式错误
Date d3(Month(3), Day(30), Year(1995)); //正确
将
class Month{
public:
static Month Jan(){return Month(1);} //用函数替换对象,避免初始化出现问题(item4)
static Month Feb(){return Month(2);}
...
static Month Dec(){return Month(12);}
private:
explicit Month(int m); //explicit禁止参数隐式转换,private禁止用户生成自定义的月份
...
};
Date d(Month::Mar(), Day(30), Year(1995)); //正确
预防错误的另一个办法是,限制类型内什么操作允许,什么操作不允许,常见的限制是加上const
。const
修饰
if(a * b = c)... // 应该是a * b == c
-
保证接口设计的一致性
STL 容器的接口设计保证了高度的一致性,例如每个STL 容器都是通过size()
成员函数返回容器中对象的个数。而在Java 中,数组使用length
属性,List 使用size()
成员函数。这样的不一致性会给开发人员带来不便。 -
避免要求用户必须进行某些操作 任何要求用户记得做某些事情的接口都容易造成误用,因为用户很可能忘记完成。例如动态分配了一个资源,要求用户以特定的方式释放资源。
Investment* createInvestment();
对于上述接口,要求用户在资源使用完后进行释放,但是用户可能产生两种错误:忘记释放资源;多次释放资源。解决方法是用智能指针来进行管理资源,为了避免用户忘记把函数的返回值封装到智能指针内,我们最好让这个函数直接返回一个智能指针对象:
std::shared_ptr<Investment> createInvestment();
实际上,返回一个智能指针还解决了一些列用户资源泄漏的问题,如shared_ptr
允许在建立智能指针时为它指定一个资源释放函数createInvestment()
得到一个getRidOfInvestment()
来进行资源释放。我们必须把getRidOfInvestment()
绑定到
Note:总结
- 一个良好的接口应该保证其不容易被误用。我们在设计接口时要努力实现这个目标。
- 保证正确使用的方法包括保证接口的一致性,以及自定义类型的行为与内置类型的行为保持一致。
- 避免无用的方法包括定义新的包装类型、限制类型的操作、限制取值范围、避免让用户负责管理资源等。
shared_ptr 支持绑定自定义的删除器,实现想要的析构机制,可以有效防范DLL 交叉问题。
item19 把类当作类型来设计
在面向对象编程的语言中,当定义一个新
-
新类型的对象如何被创建和销毁?
- 这影响了类的构造函数和析构函数,以及内存分配函数和释放函数
(operator new, operator new[], operator delete, operator delete[]) 的设计。
- 这影响了类的构造函数和析构函数,以及内存分配函数和释放函数
-
对象的初始化和对象的赋值应该有什么区别
- 这决定了构造函数和赋值操作符的行为,以及其间的差异。初始化用于未创建的对象,赋值适用于已创建的对象。
-
新类型的对象如果作为值进行传递有什么意义
- 拷贝构造函数决定了一个
type 的值传递如何实现的。
- 拷贝构造函数决定了一个
-
新类型的合法值有什么限制
- 通常情况下,并不是所有的成员变量是有效的。为了避免函数抛出异常,我们要在成员函数中堆变量进行错误检查工作,尤其是构造函数、赋值操作符和所谓的
setter 函数。
- 通常情况下,并不是所有的成员变量是有效的。为了避免函数抛出异常,我们要在成员函数中堆变量进行错误检查工作,尤其是构造函数、赋值操作符和所谓的
-
新的类型是否存在继承关系
- 如果新的类型继承自已有的类型,类型的设计就会受到被继承类的约束,比如说函数是否为虚函数。
-
新类型允许进行什么样的转换
- 新类型的对象可能被隐式地转换成其他类型,需要决定是否允许类型的转换。如果希望把
T1 隐式转换成T2 ,可以在class T1 中定义一个类型转换函数(operator T2) ,或者在class T2 内写一个可被单一实参调用(non-explicit-one-argument) 的构造函数。如果进行显式转换,需要定义个显式转换的函数(item15) 。
- 新类型的对象可能被隐式地转换成其他类型,需要决定是否允许类型的转换。如果希望把
-
哪些运算符和函数对于新类型是合理的
- 这决定了新类型中需要声明哪些函数,包括成员函数,非成员函数,友元函数等。
-
哪些标准函数是需要被禁止的
- 将不希望编译器自动生成的标准函数声明为
private(item6) 。
- 将不希望编译器自动生成的标准函数声明为
-
谁可以访问新类型中的成员
- 这决定了成员函数的访问级别是
public ,protected 或private 。
- 这决定了成员函数的访问级别是
-
新类型中的“隐藏接口”是什么
- 新类型对于性能、异常安全性、资源管理有什么保障,需要在代码中加上相应的约束条件。
-
新类型的通用性如何
- 如果需要新类型适用于多种类型,应该定义一个类模板
(class template) ,而不是单个class 。
- 如果需要新类型适用于多种类型,应该定义一个类模板
-
是否真的需要一个新类型
- 如果只是定义新的派生类以便为既有类增加功能,定义一些非成员函数或者函数模板更加划算。
Note:总结
- 设计
class 就是设计type ,在定义一个新的type 前,要充分考虑到上述问题。
item20 用常量引用传递代替值传递
默认情况下,
class Person
{
public:
Person();
virtual ~Person();
...
private:
std::string name;
std::string address;
}
class Student:public Person
{
public:
Student();
~Student();
...
private:
std::string schoolName;
std::string schoolAddress;
}
bool validateStudent(Student s);
// 以值传递的方式调用函数
Student stu;
bool isOK=validateStudent(stu); // 调用函数,以值传递的方式传递参数
当上述函数被调用时,发生了以下过程:
Student 类的拷贝函数被调用,用来初始化参数s - 当
validateStudent 函数返回时,s 被销毁
因此,当函数
bool validateStudent(const Student& s);
传递常量引用的效率要高得多,没有任何构造函数或析构函数被调用,因为没有任何对象被调用。参数声明中const
是十分重要的,这样可以避免传入的参数在函数内被修改。
以传引用的方式传递参数可以避免对象切割
class Window
{
public:
...
std::string name() const;
virtual void display() const;
}
class WindowWithScrollBars:public Window
{
public:
...
virtual void display() const;
}
void printNameAndDisplay(Window w) // 值传递,会产生对象切割
{
std::cout<<w.name();
w.display();
}
display()
是虚函数,在两个类中有不同的实现。如果使用值传递就会导致对象被切割,函数内调用的永远是基类
void printNameAndDisplay(const Window& w)
{
std::cout<<w.name();
w.display();
}
在
Note:总结
- 尽量用常量引用传递替换值传递,前者通常比较高效,并可以避免对象切割问题。
- 对于内置类型,
STL 迭代器和函数对象,值传递更加高效。
item21 不要在需要返回对象时返回引用
在
class Rational
{
public:
Rational(int numerator=0,int denominator=1);
...
private:
int n,d;
friend const Rational operator*(const Rational& lhs,const Rational& rhs);
}
上述代码中,
// 在栈上创建
const Rational& operator*(const Rational& lhs,const Rational& rhs)
{
Rational result(lhs.n*rhs.n,lhs.d*rhs.d); // 在栈上创建对象
return result; // result的生命周期结束,被析构
}
在上述代码中,
const Rational& operator*(const Rational& lhs,const Rational& rhs)
{
Rational* result = new Rational(lhs.n*rhs.n,lhs.d*rhs.d); // 在堆上创建对象
return *result;
}
上述代码在堆上创建
const Rational& operator*(const Rational& lhs,const Rational& rhs)
{
static Rational result; // 创建静态对象
...
return result;
}
// 调用
Rational a,b,c,d;
if((a*b)==(c*d)) // 等价于operator==(operator*(a,b),operator*(c,d))
{
... // 分支语句1
}
else
{
... // 分支语句2
}
在上述代码中,无论
上面所举的反例,是为了说明当一个函数必须要返回一个新的对象时,为了代码安全我们应该接受值传递带来的构造和析构成本。
const Rational operator*(const Rational& lhs,const Rational& rhs)
{
return Rational(lhs.n*rhs.n,lhs.d*rhs.d);
}
Note:总结
- 不要让函数返回一个指向局部变量的指针或引用,否则会造成资源泄漏和程序崩溃。
- 不要让函数返回一个指向局部静态对象
( 程序中可能需要多个这样的对象) 的指针或引用。 - 在面临返回引用还是返回值的选择时,优先保证程序能正常运行。
item22 类的数据成员声明为private
本
-
保证语法的一致性 在
item18 中强调良好的接口应该保证其设计的一致性。如果成员变量不是public ,那么能够访问成员变量的唯一方法就是通过成员函数。如果public 接口内的都是函数,用户就不需要在访问成员时在意是否该使用小括号。 -
精确控制对成员变量的处理 如果成员变量是
public ,任何人都可以进行读写。如果将其私有化,通过函数获取或者设定其值,可以实现“不准访问”、 “只读”、 “只写”以及“读写”。
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: //这个成员既不能读也不能写
int readOnly; //这个成员只读
int readWrite; //这个成员可读可写
int writeOnly; //这个成员只写
}
- 保证类的封装 如果通过函数来访问成员变量,便于更改某个计算过程来替换成员变量,而用户不用关注其内部实现是否发生变化。例如,对于一个自动测速程序,当汽车通过时,其速度便被计算并被填入速度收集器。
class SpeedDataCollection
{
public:
void addValue(int speed); // 把当前测得的速度放进数据集
double averageSoFar() const; // 利用数据计算平均速度
}
对于函数averageSoFar()
,我们有两种思路进行实现:第一,在类中定义一个成员变量,记录所有速度的平均值,当averageSoFar()
被调用时,只需要返回该变量就可;第二,每次调用averageSoFar()
函数时都重新计算平均值,此函数可以访问速度数据集中每一个值。两种方法可以适用于不同的环境,方法一averageSoFar()
高效,但是要为累积总量、数据点数以及平均值分配存储空间,比较消耗内存;方法二中调用averageSoFar()
时才进行计算,执行较慢。方法一适用于对反应速度有要求的情况,方法二适用于内存比较紧张的机器上
Note:总结
- 成员变量要声明为
private ,这样可以保证接口的一致性、可以进行精确的访问权限控制,并提供class 多种功能实现的弹性。 protected 并不比public 更具有封装性。
item23 用非成员且非友元函数来替换成员函数
这个
class WebBrowser
{
public:
void clearCache();
void clearHistory();
void removeCookies();
void cleanEverything(); // 调用上面三个函数
...
}
在上面代码中,声明了一个成员函数cleanEverything()
实现一键清除。这个功能也可以通过一个非成员函数进行实现:
void clearBrowser(WebBrowser& wb)
{
wb.clearCache();
wb.clearHistory();
wb.removeCookies();
}
上面两种实现方法,哪种更好?在面向对象编程中,数据以及执行数据操作的函数应该被捆绑在一起,这意味着成员函数的方案更好。事实上,这是不正确的,这是对面向对象真实意义的误解。面向对象要求数据应该尽可能的被封装,与直观判断相反,成员函数cleanEverything()
的封装性要比非成员函数clearBrowser(WebBrowser&)
低。此外,提供非成员函数可以为
- 上面的讨论只适用于非成员且非友元函数,而不是非成员函数。因为友元函数和成员函数对封装性的影响是相同的。
- 上面所谓的“非成员函数”并不意味着其不能是其他类的成员函数。例如我们可以让
clearBrowser(WebBrowser&)
成为某个工具类的静态成员函数,只要它不是WebBrowser 的成员或友元函数,就不破坏WebBrowser 的封装性。
clearBrowser(WebBrowser&)
函数既不是成员函数也不是友元函数,没有对
namespace WebBrowserStuff
{
class WebBrowser{...};
void clearBrowser(WebBrowser&);
}
将两者放在同一个命名空间下还有另外一个作用。与
// 头文件webbrowser.h,WebBrowset的核心功能以及所有用户都使用的便利函数
namespace WebBrowserStuff
{
class WebBrowser{...};
...
}
// 头文件webbrowserbookmarks.h
namespace WebBrowserStuff
{
... // 书签相关的便利函数
}
// 头文件webbrowsercookies.h
namespace WebBrowserStuff
{
... // Cookies管理相关的便利函数
}
其实,这正是vector
的功能,就不需要#include <memory>
,这允许用户只对他们使用那一小部分系统形成编译依赖。
将所有便利函数放在隶属于同一命名空间的多个头文件,意味着用户可以轻松扩展这一组便利函数。用户所需要做的就是添加更多非成员且非友元函数到命名空间内,如用户为
Note:总结
- 尽可能用非成员且非友元的函数替换成员函数,这样可以增加类的封装性、包装弹性和功能扩展性。
- 命名空间可以分布在不同的编译单元中,减小编译依赖性。
item24 如果参数要进行类型转换,该函数不能作为成员函数
在导论中提到,
class Rational
{
public:
Rational(int numerator = 0, int denominator = 1);
//构造函数专门不声明为explicit来允许从int到Rational的隐式转换
int numerator() const;
int denominator() const;
const Rational operator*(const Rational& rhs) const;
...
};
作为有理数,我们可以定义各种算术运算符,例如加减乘除。上述代码中,我们采用常规的将
// 可以对任意两个有理数对象进行相乘
Rational oneEighth(1,8);
Rational oneHalf(1,2);
Rational result = oneEighth * oneHalf; //编译通过
result = result * oneEighth; //编译通过
// 将一个对象和int进行相乘
result = oneHalf * 2; //编译通过,等价于result = oneHalf.operator*(2),int进行了隐式类型转换
result = 2 * oneHalf; //编译错误,等价于result = 2.operator*(oneHalf)
在上述代码中,一个对象和oneHalf
是一个包含operator*
函数的对象,编译器可以调用该函数,然而整数operator*
成员函数。虽然编译器会在命名空间内或operator*
函数:
result = operator*(2, oneHalf);
但是本例中并没有声明这样一个函数,所以编译器会报错。
对于运行正确的代码,其发生了隐式类型转换,编译器知道传递的是
const Rational tmp(2);
result = onHalf*tmp;
上述情况是因为构造函数是
result = oneHalf * 2; //编译错误,构造函数声明为explicit,int便不能转换为Rational
result = 2 * oneHalf; //编译错误,等价于result = 2.operator*(oneHalf)
总结下来,只有当参数位于参数表里时才可以进行隐式转换。我们这里的operator*
作为成员函数,只能对乘号右边的函数参数进行隐式转换,而不在参数表里的参数,即调用成员函数的对象不能进行隐式转换。
然而,我们还是希望两个语句都可以编译通过,这也满足我们对乘法交换律的认识。解决方案就是将operator*
声明为非成员函数,即允许编译器在每个实参上进行隐式类型转换。
class Rational
{
public:
Rational(int numerator = 0, int denominator = 1);
//构造函数专门不声明为explicit来允许从int到Rational的隐式转换
int numerator() const;
int denominator() const;
... // 不声明operator*
}
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*
完全可以由
本条款讨论的内容只是限于面向对象编程这一条件下,当我们在
Note:总结
- 如果某个函数所有的参数都可能需要隐式转换,这个函数必须作为非成员函数。
item25 考虑写一个高效的swap 函数
namespace std
{
template<typename T>
void swap(T& a,T& b)
{
T temp(a);
a=b;
b=temp;
}
}
只要类型
class WidgetImpl
{
public:
...
private:
int a,b,c; // 可能有很多数据
std::vector<double> vec; // 意味着复制需要很长时间
}
class Widget
{
public:
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs)
{
...
*pImpl = *(rhs.pImpl); // 复制Widget时,让其复制WindgetImpl对象
...
}
private:
WidgetImpl* pImpl; // 指针所指向的对象内包含Widget数据
}
这样一来,当需要交换两个对象时,直接交换指针即可,不需要交换成千上万的数据。但是
namespace std
{
template<> // 表示这是一个std::swap函数完全特殊化的实现
void swap<Widget>(Widget& a,Widget& b) // 当T是Widget时,会调用这个版本的函数
{
swap(a.pImpl,b,pImpl); // 只需要置换Widget对象的pImpl指针即可
}
}
通常情况下,我们不能改变
class Widget
{
public:
void swap(Widget& other)
{
using std::swap;
swap(pImpl,other.pImpl);
}
}
namespace std
{
template<>
void swap<Widget>(Widget& a,Widget& b)
{
a.swap(b);
}
}
这样就可以通过编译,而且这也是
上面讨论的对象是类,如果
template<typename T>
class WidgetImpl{...}
template<template T>
class Widget{...}
但是特殊化
namespace std
{
template<typename T>
void swap<Widget<T>>(Widget<T>& a,Widget<T>& b)
{
a.swap(b);
}
}
但是
namespace std
{
template<typename T>
void swap(Widget<T>& a,Widget<T>& b)
{
a.swap(b);
}
}
重载函数模板是没有问题的,但是
namespace WidgetStuff
{
template<typename T>
class Widget<...>
...
template<typename T> // 非成员的swap函数,在用户自定义命名空间内
void swap(Widget<T>& a,Widget<T>& b)
{
a.swap(b);
}
}
上述代码的好处是能把我们自定义的所有类相关的功能整合在一起,在逻辑上和代码上都更加的简洁。这也符合
重载函数模板的方法既适用于类也适用于类模板,但是对
上面讨论的是针对
template<typename T>
void doSomething(T& obj1,T& obj2)
{
...
swap(obj1,obj2);
...
}
上述代码中应该调用的
template<typename T>
void doSomething(T& obj1,T& obj2)
{
using std::swap; // 令std::swap在此函数可用
...
swap(obj1,obj2); // 为T类型对象调用最佳swap版本
...
}
当编译器看到调用
std::swap(obj1,obj2);
到现在为止,我们讨论了默认的
- 如果默认的
std::swap 对效率没有太大影响,可以直接使用一般化版本。 - 如果默认的
std::swap 效率不足( 类或模板实现了pimpl 方法) ,应该尝试进行以下操作:- 提供一个
public swap 成员函数,让它高效的交换自定义类型的两个对象的值,而且这个函数禁止抛出异常(item29) - 在类或模板所在的命名空间提供一个非成员
swap 函数,并令它调用上述swap 成员函数 - 如果所写的是一个类而非类模板,可以为该类特殊化一个
std::swap ,并让其调用swap 成员函数
- 提供一个
- 如果调用
swap ,确定要包含using 声明式,以便让编译器可以访问std::swap 。另外swap 的调用不要加namespace 修饰符。
成员
Note:总结
- 当默认的
std::swap 函数效率不高时,可以在类中提供一个swap 成员函数,但是要保证其不抛出异常。 - 如果提供了一个成员
swap 函数,相应的要提供一个非成员的swap 函数来调用这个成员函数。对于类( 非模板) 来说,还要特殊化std::swap 。 - 在调用
swap 函数时,要针对std::swap 使用using 声明式,swap 的调用函数前不加任何命名空间修饰符。 - 为自定义的类型完全特殊化
std 模板是好的,但是不要尝试在std 命名空间中添加任何东西。