IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> C++知识库 -> Effective C++ (五)实现 -> 正文阅读

[C++知识库]Effective C++ (五)实现

实现这一章主要解决以下问题:

  • 太快定义变量会导致低效率
  • 过度使用转型代码变慢且难维护
  • 类返回句柄会破坏封装
  • inlining可能导致的代码膨胀
  • 文件依存可能导致编译速度慢

条款26 尽可能延后变量定义式的出现时间

这个条款简单来说就是告诉我们定义变量是需要成本的,顺带还提了一下直接拷贝构造构造后赋值的效率问题。

程序执行到一个对象的定义,就会产生构造和析构成本,有些对象可能你根本不会使用,但是程序还是默默做了这些无用操作。有时候这种不会使用的对象并不是这么明显,如:

std::string encryptPassword(const std::string& password)
{
	using namespace std;
	string encrypted;
	if(password.length()<MinimumPasswordLength){
		throw logic_error("Password is too short");
	}
	...                        //经过一系列的加密将会返回一个加密的字符串
	return encrypted;
}

如果密码长度合适,encrypted字符串被构造和析构都是必须的,但长度太短,程序因为异常提前被结束encrypted傻乎乎的被构造和析构,完全没有必要。

稍微挪动以下位置就可以避免这种情况:

std::string encryptPassword(const std::string& password)
{
	using namespace std;

	if(password.length()<MinimumPasswordLength){
		throw logic_error("Password is too short");
	}

	string encrypted;
	...                        //经过一系列的加密将会返回一个加密的字符串
	return encrypted;
}

这还不够好,因为encrypted进行了默认构造后赋值,其实可以跳过毫无意义的default 构造:

std::string encryptPassword(const std::string &password)
{
	...						//检查长度
	std::string encrypted;	//默认构造
	encrypted=password;		//赋值
	encrypt(encrypted);	    //加密处理
	return encrypted;		//返回
}

其实可以做的更好:

std::string encryptPassword(const std::string &password)
{
	...								//检查长度
	std::string encrypted(password);//拷贝构造比默认构造后赋值要高效
	encrypt(encrypted);	    		//加密处理
	return encrypted;		  		//返回
}

循环中的构造该如何处理?

//方法A:定义在循环外
Widget w;
for(int i=0;i<n;i++)
{
	w=fun(i);
}
//方法B:定义在循环内
for(int i=0;i<n;i++)
{
	Widget w=fun(i);
}

做法A:一个构造 + 一个析构 + n个赋值
做法B: n个构造 + n个析构

这时候就看你自己权衡“赋值”成本与“构造+析构”成本了。

条款27 尽量少做转型动作

C++的设计目标之一就是保证“类型错误”绝不可能发生。旧时转型风格(old-style casts):

(T) expression
T(expression)

C-style几乎允许你将任何类型转换成任何其他类型,这对于C++领域,太过宽泛,如pointer-to-const-object转型为pointer-to-non-const-objectpointer-to-base-object转型成pointer-to-derived-class-object。为了更加清晰的表达C++中的转换,C++导入了四种新的转型操作符(cast operators):

  • const_cast 常量性移除(cast away the constness)
  • dynamic_cast 安全向下转型(safe downcasting),唯一一个C-style cast不能完成的转型,用在继承关系的转换
  • reinterpret_cast 执行低级转型,如pointer to int 转换成int
  • static_cast 强制隐式转换(implicit conversion)注意它不能完成const到non-const的转换,这个转换只有const_cast能完成

新式转换的优点是,容易辨识和避免转换错误。作者在这里谈到了唯一使用旧时转型的情况:当我要调用一个explicit构造函数将一个对象传递给一个函数时。例子如下:

class Widget{
public:
	explicit Widget(int size);
	...
};
void doSomeWork(const Widget &w);
doSomeWork(Widget(15));//构造函数是explicit的,需要使用到C-style函数式转型
doSomeWork(static_cast<Widget>(15));//这个是C++-style的转型

将int转换成Widget看上去并不是一个转型动作,不如使用C-style类型来的简洁。转型操作不是简单的将一个事物视作另一个事物,而是会让编译器真正产生运行期间执行的代码:

int x,y;
double d=static_cast<double>(x)/y;//int和double底层不一样,因此编译器必然产生了代码

再来看一个例子:

class Base{...};
class Derived:public Base{...};
Derived d;
Base * pb=&d;//隐式进行Derived到Base的转换

一个派生类对象可能被基类或者同类指针指向,请注意!派生类需要一个偏移量来保证使用到基类,对象是如何布局的和编译实现有关。有很多应用框架(application frameworks)都要求派生类中的virtual函数代码第一个动作就执行基类的函数:

class Window{
public:
	virtual void onResize(){...}
	...
};
class SpecialWindow:public Window{
public:
	virtual void onResize(){
	static_cast<Window>(*this).onResize();
	...//这里进行SpecialWindow专属行为
	}
}

虽然在派生类中主动使用转型动作,我们期望onResize作用于派生类对象,可事实上它作用转型动作动作产生的一个*this对象的base class 成分,请注意是副本进行了onResize,派生类实体的base成分是不确定,对于onResize而言就是,派生类成分确定,但是基类成员并不确定。

dynamic_cast在许多时间版本执行时间都相当慢,层次较深和多重继承执行效率更是如此。那为什么还需要dynamic_cast呢?有时候是不得不这样做,通常是因为你想在一个你认定为派生类对象身上指针派生类操作,但是你手头上只有指向基类的指针或引用,你只能靠它们处理对象。

假设我们想要使用Window来调用SpecialWindow的Blink方法:

class Window{...};
class SpecialWindow:public Window{
public:
	void blick();
	...
}

typedef 
std::vector<std::shared_ptr<Window>> VPW;
VPW winPtrs;
...

//下面是一个接口部分的处理
//因为使用了基类作为接口
//对于派生类的blink需要强制转换
for(VPW::iterator iter=winPtr.begin();iter!=winPtrs.end();++iter)
{
		if(SpecialWindow * psaw=dynamic_cast<SpecialWindow *>(iter->get()))//强制转换成基类,调用blink
		psw->blink();
	}
)

我们知道这存在效率问题,所以作者提供了两种避免使用dynamic_cast对象的方法:

  • 使用容器并在其中存储直接指向派生类对象的指针
  • 通过基类接口处理所有派生类

第一种处理如下:


typedef std::vector<std::shared_ptr<SpecialWindow>> VPSW;
VSPW winPtr;
...
//接口处理,将需要处理的派生类作为容器参数传入即可
for(VSPW::iterator iter=winPtrs.begin();iter!=winPtrs.end());++iter)
{
	*(iter)->blink();
}

简单来说,就是更改接口为派生类而不是基类。缺点非常明显,我们只能处理一类的派生类。

第二种处理灵活使用了动态绑定概念:

class Window{
public:
	virtual void blink(){}//基类实现一个什么都不做的代码
	...
};
class Special:public Window{
public:
	virtual void blink(){...}//派生类功能实现
	...
}
typedef std::vector<std::shared_ptr<Window>> VPW;
VPW winPtrs;
...
//接口处理
for(VPW::iterator iter=winPtrs.begin();iter!=winPtrs.end();++iter)
{
	(*iter)->blink();
}

接口仍然是Window(基类),但是不再需要dynamic_cast,相较于方法一,不同的派生类只需要对应实现对应的blink即可。

最后,作者给出了几点经验:

  • 连串(cascading)dynamic_cast产生的代码又大又慢,应该用virtual方法代替他
  • 转型动作最好隐藏在函数内,免得玷污了操作者的手

条款28 避免返回handles指向对象内部成分

handle被翻译为号码牌(用于取得某个对象),引用迭代器和指针都是handle。

假设你正在定义一个矩形,矩形可以由左上角和右上角的点表示,为了让这个矩形所占空间足够小,你可能决定将这两个点放在一个结构体中,再让Rectangle去指向它:

class Point{
public:
	Point(int x,int y);
	...
	void setX(int newVal);
	void setY(int newVal);
	...
};

struct RectData{
	Point ulhc;//upper left-hand corner
	Point urhc;//upper right-hand corner
};

class Rectangle{
	...
private:
	std::shared_ptr<RectData> pData;
};

用户可能会以只读方式获取两个点,因此定义了两个返回对应点的方法:

class Rectangle{
public:
	...
	Point& upperLeft()const{return pData->ulhc;}//这里声明为const是为了让const Rectangle也可以用这个方法
	Point& lowerRight()const{return pData->lrhc;}
	...
};

假设用户指向想要获取这个点而不是修改这个点,这样返回handle的隐患在于:

Point coord1(0,0);
Point coord2(100,100);
const Rectangle rec(coord1,coord2);//const Rectangle表示这个矩形不想被改变
rec.upperLeft().setX(50);//因为暴露了其handle,他并没有做到“矩形不想被改变”的设想

这也就告诉我们:

  • 成员变量的封装性最多只等于“返回其handle”的函数访问级别
  • 若const成员函数传出一个handle,所指的数据与自身有关但是又被存于别处,则其const属性可能被改变

对于第二个可以将引用改成const引用;对于第一个没有办法避免,而且存在严重的问题,返回一个空悬(dangling)指针(临时对象)。总之避免返回handle指向对象内部,可以增加封装性,帮助const对象为真的const,同时降低了空悬指针的情况。当然对于operator[]是个例外,但不是常态。

条款29 为“异常安全”而努力是值得的

异常是某段程序预期外的事件,如数据库断开、错误的输入。出现异常后,程序将会终止执行,并进入相应的异常处理模块。还是以书中的例子作为说明:

class PrettyMenu
{
public:
	...
	void changeBackground(std::istream &imgSrc);//改变背景图像
	...
private:
	Mutex mutex;								//互斥锁
	Image * bgImage;							//指向当前背景图像
	int imageChanges;							//背景图像被改变的次数
}

下面是changeBackground的实现:

void PrettyMenu::changeBackground(std::istream &imgSrc)
{
	lock(&mutex);
	delete bgImage;
	++imgChanges;
	bgImage=new Image(imgSrc);
	unlock(&mutex);
}

从异常安全性的角度来看,上述程序非常糟糕。糟糕之处在于,当new申请内存失败时:

  • 互斥锁永远不会释放(问题1)
  • 对象状态被污染(被改变的次数+1,事实上他并没有成功)(问题2)

新标准下,直接使用lock_gurad更为简洁。

异常安全函数(Exception-safe functions)是在异常出现时调用的函数,该函数处理异常有三个等级。

  • 基本保证
  • 强烈保证
  • 不投掷(nothrow)保证

从上到下依次严格。基本保证是指程序任何事物发生异常后保持在有效状态下,是一个合法性保证;强烈保证是指异常发生后要么成功,要么失败返回原状态,是一个原子态保证;不投掷保证是指异常永远不会发生,如int、指针,是异常代码的关键保障。

一个异常安全的代码必须是上述等级中的一个,否则不是一个异常安全代码。如果可能,我们当然希望每个功能函数都不抛出异常,即不投掷保证,如果你的代码涉及到动态分配内存,你几乎无法保证不抛出std::bad_alloc异常,因此我们代码通常是在前两个保证中做出选择。

回到刚刚那个例子,为了解决问题1,我们可以使用RAII互斥锁解决,实现代码如下:

void PrettyMenu::changeBackground(std::istream &imgSrc)
{
	std::lock_guard<std::mutex> lockGurad(lo);
	delete bgImage;							//删除原对象
	++imgChanges;
	bgImage=new Image(imgSrc);
}

注意到这种方式可能会有内存泄露的风险,因此可以将指针bgImage用只能指针来管理:

class PrettyMenu
{
	...
	std::shared_ptr<Image> bgImage;
	...
}
void PrettyMenu::changeBackground(std::istream &imgSrc)
{
	std::lock_guard<std::mutex> lockGurad(lo);
	++imageChanges;
	bgImage.reset(new Image(imgSrc));
}

稍微更改以下++imageChanges;的位置就可以解决问题2。

void PrettyMenu::changeBackground(std::istream &imgSrc)
{
	std::lock_guard<std::mutex> lockGurad(lo);
	bgImage.reset(new Image(imgSrc));
	++imageChanges;
}

解决了这两个问题,基本上可以满足强烈保证,虽然它到导致了istream流记号的读取记号被移走(严格意义上,他也不是一个强烈保证)。其实有一个更加典型的策略用来实现强烈保证,那儿就是copy and swap策略,copy原对象,对副本做操作,最后swap到原对象。

//这个类包含所有资源,包括图像对象和变换次数
struct PMImpl{
	std::shared_ptr<Image> bgImage;
	int imageChanges;
};
class PrettyMenu{
	...
private:
	std::mutex mtx;
	std::shared_ptr<pMImpl> pImpl;
};
void PrettyMenu::changeBackground(std::istream & imgSrc)
{
	//目标是替换原对象的图片
	using std::swap;
	std::lock_guard<std::mutex> lockguard(lo);		 //解决互斥锁潜在问题
	std::shared_ptr<PMImpl> pNew(new PMImpl(*pImpl));//pNew指向旧对象资源
	pNew->bgImage.reset(new Image(imgSrc));			 //pNew拷贝旧资源
	++pNew->imageChanges;						     //pNew累加状态

	swap(pImpl,pNew);								 //swap完成旧资源更新
}

实现上开辟新的资源空间并用指针指向这块区域,然后利用这个指针拷贝旧对象资源(或者更新状态),最后将新资源和旧资源进行互换。作者将这个实现方法叫做pimpl idoim 指向实现(副本) 。为什么要另外将资源放在一个结构体内?注意是两个问题,一是资源为什么要独立成PMImpl而不是放在PrettyMenu;二是为什么不是类,原因是独立成PMImp有利于我们独立异常安全代码,放在结构体是因为PrettyMenu已经完成封装,直接设置成结构体更加方便。

copy-and-swap策略可以实现强烈的异常安全性,但是整个函数的异常安全性等级遵循“木桶理论”。

void someFunc()
{
	...//新对象指针指向原状态
	f1();
	f2();
	...//swap之
}

f1、f2的异常等级会影响someFunc的整体异常等级。pimpl idoim实现是需要空间和时间,如果实现这个带来的是效率和复杂度的激增,那么你最起码也要提供一个基本保证。

条款30 透彻了解inlining的里里外外

inline函数是否真正inline取决于编译器。

其优点是:

  • 调用开销免除
  • 比宏要好

缺点是:

  • 代码膨胀
  • 编译负担
  • inline函数无法随着库函数升级而升级,因为全部要重新编译

其他事项:

  • Inline函数一定放在头文件中,因为编译器需要进行替换
  • 不要只因为function template出现在头文件将其声明为inline

作者举了几个例子:

  • 构造和析构可以内联,但往往无效。这是因为编译器为了完成这两部分工作可能增加了一些调用,大概率会导致inline失效
  • 如果用户尝试取函数地址,那么编译器也会拒绝inline
inline void f(){}
void (*pf)()=f;
...
f();//inline
pf();//函数指针形式,编译器不进行inline

作者给出的inline策略是:一开始不要将任何函数声明为inline,或者inlining 范围缩小至一定要inline或非常平淡无奇的函数。最后作者给出一个80-20法则:平均而言一个程序往往将80%的执行时间花费在20%的代码上。

条款31 将文件间的编译依存关系降至最低

这个条款是为了提高编译速度的。

下面是Person类的定义:

class Person
{
public:
    Person(const std::string &name,const Date &birthday,const Address &addr);
    std::string name()const;
    std::string birthDate()const;
    std::string address()const;
private:
    std::string theName;
    Date theBirthDate;
    Address theAddress;
}

编译无法通过,这是因为编译器必须取得代码实现所有到的classes,string、Date和Address的定义,这样的定义通常是由预处理指令#include提供的,补上这些头文件:

#include <string>
#include "date.h"
#include "address.h"

class Person
{
public:
    Person(const std::string &name,const Date &birthday,const Address &addr);
    std::string name()const;
    std::string birthDate()const;
    std::string address()const;
private:
    std::string theName;
    Date theBirthDate;
    Address theAddress;
};

这样一来,Person定义文件和#include引入的文件之间形成了一种编译依存关系(compilation dependency),一旦引入文件发生改变就会导致引用他们的文件发生重新编译。看了半天,其实他是说声明和实现放在同一个头文件中这种情况,这也是为什么我们要实现接口和实现分属两个不同文件的原因,因为只要接口不变,#include改变实现就只会影响到#include本身源文件的重新编译,避免连串编译依存关系(cascading compilation dependencies)。

编译器获得对象大小的唯一方法是询问class定义式。这个问题在Java、Smalltalck等语言并不存在,因为这类语言只分配足够空间给一个指针(用于指向该对象)使用,这种实现方式叫做pimpl idiom(pointer to implementation)。

#include <string>
#include <memory>

class PersonImpl;
class Date;
class Address;

class Person
{
public:
    Person(const std::string &name,const Date &birthday,const Address &addr);
    std::string name()const;
    std::string birthDate()const;
    std::string address()const;
private:
	std::shared_ptr<PersonImpl> pImpl;

这样一来,Person类的使用者,在修改任何实现都不会引起重新编译,分离的关键在于“声明依赖性”替换“定义依赖性”,只要声明不变,客户使用这个类就不进行编译。作者总结的几点:

  • 如果使用object references或object pointers可以完成任务,就不要使用object
  • 如果能够,尽量以class声明式替代class定义式
  • 为声明和实现提供不同文件

另一种方法是让Person成为抽象基类,也可以叫做接口类(Interface class),这个暂时不了解,有空回来看。

  C++知识库 最新文章
【C++】友元、嵌套类、异常、RTTI、类型转换
通讯录的思路与实现(C语言)
C++PrimerPlus 第七章 函数-C++的编程模块(
Problem C: 算法9-9~9-12:平衡二叉树的基本
MSVC C++ UTF-8编程
C++进阶 多态原理
简单string类c++实现
我的年度总结
【C语言】以深厚地基筑伟岸高楼-基础篇(六
c语言常见错误合集
上一篇文章      下一篇文章      查看所有文章
加:2021-08-20 14:54:28  更:2021-08-20 14:54:37 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年12日历 -2024/12/27 4:53:40-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码
数据统计