Effective C++ (四)设计与声明

什么是软件设计?作者认为“令软件做出你希望它做的事情”的步骤和方法。

条款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

为什么要这么做?

  1. 拷贝构造是需要成本的
  2. 继承关系中派生类失去特性

假设我们定义了一个类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;
}

静态变量始终存在直至程序退出,这也是不行的,理由如下:

  1. 多线程安全性
  2. 比较的时候永远都是现值,永远返回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+42c*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进行了探讨,这里就不继续看了,以后熟悉一下模板再看吧。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值