什么是软件设计?作者认为“令软件做出你希望它做的事情”的步骤和方法。
条款18 让接口容易被使用,不易被误用
一个接口不能假设使用者都为“讲道理的人”,开发一个容易被使用,不容易被误用的程序可以做哪些工作呢?
- 函数代替对象
以一个日期类的构造函数为例,说明接口的重要性
class Date
{
public:
Date(int month,int day,int year);
}
用户实际使用的时候可能会顺序调换,无效参数(21月)。可以使用一个外覆类型(wrapper types)来区分过滤掉用户可能不正确输入。本例而言的具体做法是:
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 Year& y, const Month& m, const Day& d){...}
}
将接口改成上面这样,用户按照你的设想输入参数:
Date d(30,3,1995); //error,类型不匹配
Date d(Day(30),Month(3),Year(1995)); //error,类型不匹配,顺序
Date d(Year(1995),Month(3),Day(15)); //ok,类型顺序都匹配
当时仍然无法阻止用户输入Month(15)这种操作,因为日期的取值是固定的,其实现阶段最佳的实现方式是enum class,在作者写这本书时尚未支持。
-
const修饰operator* 等操作符
-
尽可以能和内置类型保持一致
-
令factory模式返回指定类型
回忆之前的例子,为了保证调用者使用智能指针管理内存,将createInvestment
的返回值修改为std::shared_ptr
:
Investment * createInvestment(); //构造接口
void getRidOfInvestment(Investment*); //析构函数
std::shared_ptr<Investment> createInvestment();
默认情况std::shared_ptr是用delete回收资源的,但是这个例子却使用了一个自定义函数getRidOfInvestment
,std::shared_ptr的第二个参数是一个删除器deleter,传递之:
std::shared_ptr<Investment> createInvestment()
{
std::shared_ptr<Investment> retVal(nullptr,getRidOfInvestment);//不太清楚书中为什么坚持要用static_cast,nullptr不是可以隐式转换成任何类型吗?
//可能做一些Investment的一些特化操作
//如果能够在std::shared_ptr之前确定,那更倾向于和构造一起
//而不是指向空在指向对象,效率上可能慢一点
//retVal指向真正的对象,retVal.reset(...)
return retVal;
}
最后作者总结了一下使用智能指针的
优点:
- cross-DLL智能指针总能正确调用资源析构
- 智能指针可以消除潜在的内存泄漏
和缺点:
- 大小可能是原始指针(raw pointer)的两倍
- 需要辅助内存
- 速度稍微慢一些
条款19 设计class犹如设计type
设计class其实就是设计一个新类型,设计目标是和标准体验一致。设计之前应该考虑:
- 新的type应该如何被创建和销毁
- 对象初始化和赋值有什么差别
- 对象拷贝时行为
- 成员变量合法性是否在构造、赋值和setXXX的函数进行了检查、异常处理
- 新类型的继承图系(inheritance graph)
- 需要什么类型转换?显式还是隐式?见条款15
- 什么样的操作和函数对于对象是合理的
- 编译器默默构造的函数行为是否符合预期?条款6
- type的访问控制如何?
- 什么是新type的为声明接口
- 若为一系列的类应该考虑template
- 你真的需要定义一个新type吗?
暂时不能有更深入的理解,毕竟设计好的class是一个非常系统的工程。等以后再继续看吧,留个坑
条款20 宁以pass-by-reference-to-const代替pass-by-value
为什么要这么做?
- 拷贝构造是需要成本的
- 继承关系中派生类失去特性
假设我们定义了一个类A:
class A
{
public:
A() { std::cout << "A()" << std::endl; }
};
void fun(A t){}
传值,首先需要拷贝构造一个临时对象用于fun函数体内使用,当A中含有更多的成员时,拷贝构造的成本更大。
其次,当我们继承class A时
class a :public A
{
public:
a() { std::cout << "a()" << std::endl; }
};
void fun(A t){}
int main(()
{
a t;
fun(a);//注意派生类可以传入接受父类的参数中,但是其功能被抹去了
}
派生类可以传入接受父类的参数中,但是其功能被抹去了(slicing problem),这不是我们想要的。
如何决定是传递引用(指针)还是值?
- 内置数据类型和STL中迭代器和函数对象传值比pass-by-reference-to-const、pointer效率要高,因为其设计者已经考虑了切割和效率问题
- 如果是内置类型或STL迭代器和函数对象pass-by-value比 更加高效(int double)且避免了切割(slicing)
- 不是小对象就一定适合pass-by-reference,因为编译器可能区别对待和大小可能随着开发改变
条款21 必须返回对象时,别妄想返回其reference
这里以书中的例子为例,谈谈我对这个的看法:
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 opertator(const Rational& lhs, const Rational& rhs);
};
关于const Rational operator*(const Rational& lhs, const Rational& rhs);
该如何实现,作者讨论了几种实现方式,并指出了其中的问题。
方式一:直接返回结果
const Rational opertator(const Rational& lhs, const Rational& rhs)
{
return Rational(lhs.n * rhs.n, rhs.d * rhs.d);
}
直接返回结果能完成要求,其缺点是承担额外的拷贝和析构成本。
方式二:返回引用
const Rational& opertator(const Rational& lhs, const Rational& rhs)
{
Rational result = Rational(lhs.n * rhs.n, rhs.d * rhs.d);
return result;
}
该方式虽然不需要承担拷贝和析构成本,但在返回时,result已被析构,使用一个不存在的内存是程序的巨大缺陷。
方式三:返回new对象
const Rational & operator* (const Rational& lhs, const Rational& rhs)
{
Rational * result =new Rational(lhs.n * rhs.n, rhs.d * rhs.d);
return *result;
}
现在你能返回一个已经存在的对象了,但是何时释放?一个非常严重的错误,内存泄漏。
方式四:返回static对象
const Rational & operator* (const Rational& lhs, const Rational& rhs)
{
static Rational result;
result = Rational(lhs.n * rhs.n, rhs.d * rhs.d);
return result;
}
静态变量始终存在直至程序退出,这也是不行的,理由如下:
- 多线程安全性
- 比较的时候永远都是现值,永远返回true
最佳方法:返回inline值,并开启编译器优化
inline const operator*(const Rational & lhs,const Rational &rhs)
{
return Rational(lhs.n * rhs.n, rhs.d * rhs.d);
}
条款22 将成员变量声明为private
将成员变量声明为private的,通过函数调用的形式来读写它们的原因:
- 精准控制。是否提供get或set方法完成对该成员的读写性选择
- 封装性好。获取成员的方法改变不会影响使用者,或者说是弹性好,修改方法并不影响使用
- 约束性。get、set方法内部可以约束成员范围
这个规则同样适用于protected成员,protected成员发生改变时所有派生类都会发生改变。有经验丰富程序员都会告诉你:一旦一个成员变量声明为public或protected,而客户开始使用他,就很难改变那个成员变量涉及的一切,因为受影响的地方太多了,重新编写文档、测试、编译都是必须的。
从封装角度来说,只有提供封装(private)和不提供封装(public protected)两种。
条款23 宁以non-member、non-friend替换member函数
这个条款提到了一个之前没有听说过的面向对象守则:数据应该尽可能被封装。
OOP应尽可能的将数据封装起来,用户所看见的越少,代码的弹性就越大。弹性指的是应对变换的能力,就算你发生了改变,对整个系统冲击也不会太大。
class A
{
public:
int c;
int d;
}
class B
{
int getE(){ modifyE(); return e;};
int getF(){ modifyF(); return f;};
int setE(int e){e=e;};
int setF(int f){f=f;};
private:
void modifyE(){e=e*e+42;}
void modifyF(){e=f*e+96;}
int e;
int f;
}
int main()
{
A a;
B b;
int ef=getE()*getF(); //将这些放入类中
int cd=(c*c+42)+(c*d+96);//你需要直接操作数据
}
B将数据私有化(封装),将操作控制在类内,当方法改变时我们只需要更改对应的成员函数即可(modifyE modifyF),其实将c*c+42
和c*d+96
弄成函数也一样可以实现,不过利用不到面向对象的思想罢了(比如说状态传递)。
作者并不想从这个角度出发,作者从封装的本质出发量化了封装的本质:操作数据的可能性约小,封装性越好,成员函数对内(所属的类)封装性几乎等于0,因为无论是私有还是公有数据,他都能够操作;而non-member、non-friend仍然遵守访问规则限制,这个角度而言成员函数甚至比non-member、non-friend封装性更低。接下来作者举了一个WebBrowser的例子来进一步说明:
class WebBrowser
{
public:
...
void clearCache();
void clearHistory();
void removeCookies();
void clearEverything();//方式一,提供member方法
...
}
将所有操作整合在一起有两种方式:
- member 方法,
void clearEverything()
依次调用三个方法 - non-member、non-friend方法,
void clearBrowser(WebBrowser &wb)
依次调用三个方法
第一种member方法,可以访问的资源有:class中的全部数据,这个角度而言封装性几乎等于无;第二种方法反而具备更高的封装性,因为它仍然接受访问控制的约束。除了封装性,non-member、non-friend还有独特的、无可取代的功能,这就是表达了不是所有方法都属于一个类,他可能属于很多类。C++与和C#、java不同,后者要求所有方法都必须属于一个类,对所属的类而言,这个权力太大了,封装性不好,让C++不同的得益于namespace的特性:能够跨越多个源码文件。还是以刚刚那个WebBrowser为例:
工具(utility)是一些在多个类中能够使用的non-member、non-friend的函数,因为它调用和static成员函数一样,所以也叫做static member函数?
namespace WebBrowserStuff
{
class WebBrowser{...};
void clearBroswer(WebBrowser &wb);
}
在一个WebBrowserStuff命名空间可能有很多类,不同类可能需要不同的工具,就好像std命名空间下,有memery
vector
string
iostream
等类,不是所有的工具都会被使用,只是当我们需要某些工具时将对应的class头文件添加到std命名空间中使用。
//webbrowser.h
namespace WebBrowserStuff
{
class WebBrowser{...}; //核心机能,所有客户端都需要
... //non-member函数
}
//webbrowserbookmarks.h
namespace WebBrowserStuff
{
...//书签相关的便利函数
}
//webbrowserbookcookies.h
namespace WebBrowserStuff
{
...//cookies相关的便利函数
}
STL也是如此组织程序库的,标准库并不是拥有单一、整体、庞大得到的统一头文件的,当我们需要使用到vector相关的机能时,只需要包含对应头文件,就可以使用相关的便利函数,如vector的Non-member functions:
当你只需要用到string相关的便利函数完全没有必要进行额外的vector便利函数的依赖。
扩展便利函数也相当方便,将所有的便利函数放在多个头文件内但是隶属于一个命名空间,意味着客户可以轻松扩展这一组便利函数,只需要增加更多的non-member non-friend函数到命名空间并将其声明放在一个头文件中(实现对应源文件中),引用上这个头文件,就可以使用扩展机能,解决了class对于客户而言是不能扩充的问题。
自己扩展std空间机能:
//newSTDfunction.h
#pragma once
#include <vector>
namespace std {
void printVec(vector<double> v);
}
//newSTDfunction.cpp
#include "newSTDfunction.h"
#include <iostream>
#include <vector>
namespace std
{
void printVec(std::vector<double> v)
{
for (auto r : v)
std::cout << r << " ";
std::cout << std::endl;
}
}
//main.cpp
#include <vector>
#include "newSTDfunction.h"//引用的机能头文件
int main()
{
std::vector<double> aa{ 1,2,3,4,5 };
std::printVec(aa); //扩充的printVec机能
}
条款24 若所有参数皆需类型转换,请为此采用non-member函数
一般而言,令classes支持隐式转换是一个糟糕的主意,但是数值类型时例外。如表示一个有理数类:
class Rational{
public:
Rational(int numerator=0,int denominator=1);//故意不设置为explicit,以便完成隐式转换
private:
int n,m;
}
作为数,支持运算是其基本功能。以乘法为例,条款23提到,最好使用non-member、non-friend替代member,但是为了支持隐式转换,我们不得不将其声明为单参数函数。
const Rational operator*(const Rational &rhs)const;
如果对返回一个Rational值而不是一个引用,请查看条款21的讨论,对于两个有理数相乘自然是轻松自然:
Rational oneEigth(1,8);
Rational oneHalf(1,2);
Rational result=oneHalf*oneEigth; //没有问题
result=result*oneHalf; //没有问题
但我们还想支持内置类型与Rational的运算,这是一个再常见不过的想法了:
result=oneHalf*2; //OK,int隐式转换成Rational
result=2*oneHalf; //not OK
这不科学,毕竟交换律再正常不过了。
对于第一个语句result=oneHalf*2
,编译器找到了operator*
,很遗憾没有找到Rational*Rational
完全一致的函数,但是因为默许了隐式转换,所以不符合Rational类型的2将会被编译器做以下操作:
Rational(2);//调用Rational(int numerator=0,int denominator=1)构造函数,因为分母默认值为1
运行成功!
对于第二个语句result=2*oneHalf
虽然找到了乘法,但是只有位于参数列中内的参数才有资格进行隐式转换,显然左边的2并具备这样的转换能力。
如何解决?作者提出一个解决方法,让所有参与运算的参数都作为non-member、non-friend的参数列表,以具备隐式转换资格:
const Rational operator*(const Rational&lhs,const Rational&rhs);
那么它应该是Rationa类的友元吗?不应该,因为借助公有操作就能完成的函数没有理由去破坏封装性,事实上任何时候尽可以能少添加友元,毕竟朋友太多带来的麻烦也会越多。
条款25 考虑写出一个不抛出异常的swap函数
swap是一个有趣的函数,原本他只是STL的一部分,之后成为异常安全性编程(exception-safe programming)的脊柱,同时还是处理自我赋值错误的一种常见机制。一个典型的swap实现如下:
namespace std{
template<typename T>
void swap(T&a,T&b)
{
T temp(a); //
a=b;
b=temp;
}
}
让我们看看都进行了什么操作,首先拷贝构造temp,a赋值赋值,b赋值操作,temp析构总计四项,事实上这是没有必要的。对于这种“以指针指向一个对象,内含真正数据”的类型最佳的swap实现应该是“pimpl”(pointer to implement),看上去像是这样:
class WidgetImpl{
public:
...
private:
int a,b,c;
std::vector<double> v;
}
class Widget{ //这个class使用pimpl手法
public:
Widget(const Widget&rhs);
Widget&operator=(const Widget &rhs)//赋值时将这个资源对应的内容进行赋值
{
...
*pImpl=*(rhs.pImpl);
...
}
private:
WidgetImpl * pImpl;
}
一旦Widget需要交换,唯一要做的就是交换他们的指针,按照缺省的swap,复制了三次WidgetImpl 所指的对象,非常不高效,只需要简单修改即可避免这个问题:
namespace std{
template<> //全特化(total template specialization),表示这个函数是为Widget设计的,只有当作用于Widget才会启用
void swap<Widget>(Widget &a,Widget &b)
{
swap(a.pImpl,b.pImpl);
}
}
非常遗憾他不能通过编译,因为pImpl是private的因为它既不是friend也不是member。有两种方法可以解决它,要么是让他成为友元,要么再Widget中声明一个名为swap的函数,接着再特化的版本上调用它。
class Widget{ //这个class使用pimpl手法
public:
...
void swap(Widget &other)
{
using std::swap;
swap(pImpl,other.pImpl);
}
private:
WidgetImpl * pImpl;
}
接着作者对于模板类型的swap进行了探讨,这里就不继续看了,以后熟悉一下模板再看吧。