effective c++的老笔记(三)

条款二十二

1、务必对成员变量使用private,然后用函数读取它们,看似多次一举,实际上使用函数读取可以保证每次都进行正确的值检测,并且可以在用户无需了解的情况下计算/读取方式、可以细微得划分只读/只写。

type getNum(){}

这个函数返回的可以是一个成员的值,这个成员储存了需要的值(在内存次要时)

return member;

也可以在函数内调用相应的成员进行计算(在内存重要,调用次数不多时)

{

//计算

return result;

}

 

2、protected的封装性并没有比public好,protected中改变一个成员,破坏的是所有派生类,public则是,鬼晓得!只有private(封装)与其他(不封装),这两种封装性。

 

 

 

 

条款二十三

使用non-member函数来代替member(前提是可以)

把需要使用private成员的函数放在类里,调用一系列成员函数完成某种功能的“便利函数”定义成non-member会更好。

这样做可以避免用户去编译他们不需要的功能。若把所有的“便利函数”放到类里,那么就意味着用户要编译全部的功能。

可行的做法是把“仅仅调用成员函数的函数”放在和定义的类所在的同一个命名空间中。然后把命名空间在不同的文件展开,把不同的功能的函数放在不同的头文件中。这么做用户便只需要编译他们想要的功能部分,和必须编译的类部分。

故使用non-member函数代替member函数,可以增加封装性、包装弹性、机能扩充性。

 

 

 

 

条款二十四

如果一个函数的全部参数都可以进行类型转换,那么它只能是一个非成员函数(这些类型转换通常是单一参数的构造函数定义的隐式转换,也就是其他类型到本类型的转换)

比如operator*定义成成员函数则会导致“数值 * 类型”无法通过编译,因为operator*的第一个参数绑定了“类型”,所以只能以       “类型 * 数值”的方式使用operator*,此时就应当改成非成员函数(通过友元等实现)。

 

 

 

 

 

条款二十五

使用一个编写正确,不抛出异常的swap

 

标准库的swap实现

template<typename T>
void swap(T& lhs, T& rhs)
{
	T temp(lhs);
	lhs = rhs;
	rhs = temp;
}//拷贝并交换

简单的拷贝并交换,可能引起三次拷贝交换,因为operator=通常都需要“拷贝并交换”。也就是说,当你swap时,虽然只有三句代码,但是第一句调用了拷贝构造函数进行一次拷贝,后两句调用了operator=进行了“拷贝并交换”又发生两次拷贝(额外的临时对象)。

那么当你不需要这样的交换时,定义一个自己的版本,比如只交换内置指针,而不是构造三次对象。

对于非template类,即非模板类

定义一个swap()成员函数,再定义一个非成员函数(接受两个参数的)来调用这个函数,接着再定义一个对std::swap的偏特化版本,最好和这个类放在一起,因为使用这个类的客户应该都会需要它的偏特化swap。

namespace ForSwapStuff {
	class ForSwap {
	public:
		void swap(ForSwap& rhs)
		{
			int *temp = pointer;
			pointer = rhs.pointer;
			rhs.pointer = temp;
		}//成员函数交换private成员

	private:
		int *pointer;
	};
	void swap(ForSwap& lhs, ForSwap& rhs)
	{
		lhs.swap(rhs);
	}//非成员函数调用public swap函数保证封装
}//放入namespace,在名字查找时会代替std版本

namespace ForSwapStuff {
	//特化了std的版本,在使用std::swap时也会使用特化版本
	class ForSwap {
	public:
		void swap(ForSwap& rhs)
		{
			int *temp = pointer;
			pointer = rhs.pointer;
			rhs.pointer = temp;
		}//仅交换指针

	private:
		int *pointer;
	};
	void swap(ForSwap& lhs, ForSwap& rhs)
	{
		lhs.swap(rhs);
	}
}
namespace std {
	template<>
	void swap<ForSwap>(ForSwapStuff::ForSwap& lhs, ForSwapStuff::ForSwap& rhs)
	{
		lhs.swap(rhs);
		cout << "swap" << endl;
	}
}

//泛型模板的版本
namespace ForSwapStuff {
	template<typename T>
	class ForSwap {
	public:
		void swap(ForSwap& rhs)
		{
			int *temp = pointer;
			pointer = rhs.pointer;
			rhs.pointer = temp;
		}

	private:
		T * pointer;
	};
	template<typename T>
	void swap(ForSwap<T>& lhs, ForSwap<T>& rhs)
	{
		lhs.swap(rhs);
		cout << "swap" << endl;
	}
}

结论:

1、成员版的swap不要抛出异常,当swap进行到一部分的时候,对象的值发生改变,此时却发生了异常,会使得对象破坏。

2、如果提供了成员版的swap那么就再提供一个非成员版的swap去调用成员版的,非成员版的介绍两个此类的引用,最好再提供一个偏特化的swap,防止客户使用std:swap。事实上调用swap时应当先加上using std::swap,根据名字查找规则,编译器优先考虑自定义的版本,然后是特例化版本,最后才是系统版本。

3、可以特例化std中的函数,但是绝对不要扩充std。

条款二十六

尽量延后变量出现的位置。

最好是在变量定义时就可以为变量赋值,或者延后到变量使用的前一刻才进行定义,因为定义到使用的这个区间可能阻止了这个变量的使用,那就只是定义变量,然后释放。

对于循环中的变量,耗费的是一组构造/析构,如果不是明确知道“构造 + 析构”的效率低于一个赋值操作,那么直接在这个循环中使用它的位置中定义这个变量会更好。因为这样可以让程序的结构更加清晰(况且你的拷贝赋值运算符很可能是用拷贝并交换的方式实现的)

type a;

for(...){ use a }

for(...){ type a;use a}

条款二十七

1、尽量避免转型的发生,尤其是dynamic_cast,在多继承体系中它不可避免得需要调用多次strcmp来拷贝每个类名,还有其他的各种比较,在效率上非常不理想,也无法保证类型安全。尽量使用继承体系的virtual函数来控制器发生,比如定义一个什么也不做的virtual缺省函数,有时就可以避免一些dynamic_cast。

2、不要尝试用static_cast来转换成基类后调用基类的某个版本的函数,应该使用域作用符的方式来调用,使用static_cast转换类型后调用相当于在一个副本上调用成员函数,会造成“基类不改变,派生类被改变”的奇怪状态

Derived &d;

Base &b = d;

//根据多态,这是可行的,但是c++与java,c,c#不同的一点是,此时的d相当于有了两个不

//同的地址,一个地址是Derived(派生类)的,一个是Base(基类)的,这也是为啥//static_cast<Base>(d).成员函数(),是错的,因为这把d的基类部分“取出”,构造了一个副

//本,调用成员函数的是副本,不是d中的Base部分。

3、可以使用保存了智能指针的容器来代替cast,这样做的话一般来说你可能需要多个容器(存放基类/派生类)然后使用相应容器存放相应指针

4、尽量使用新式的转换而不是c风格的转换,甚至是在你构造一个对象传递给函数的时候,也可以用static_cast注意reinterpret_cast(其实更应该说是类型指定)是编译器相关的,不可移植。

fun( (type(...)) ) == fun( static_cast<type>(...) )

int a,b;     double c = a/b         ==          double c = static_cast<double>(a)/b

使用类似这样的显示新型转换可以让你得到更多的错误信息(当然是在你真的有出错的时候)。

5、如果你无论如何都需要转型了,那么把这个操作包装进函数,让用户调用这个函数。更好的或许还是由你自己调用这个函数,尽量不要把这个类型转换的责任交给你的客户(你并不知道你的客户会不会记得要转换)

6、绝对不要连写多个dynamic_cast、

if(Derived1 *d1 = dynamic_cast<Derived1*>(基类指针))

{ 使用Derived1的成员 }

else if(Derived2 *d2 = dynamic_cast<Derived2*>(基类指针))

{ 使用Derived2的成员 }

以此类推

 

这样做的效率极低(而且要是你的派生类够多。。。),每次有新的派生类还要添加新的条件分支,用virtual函数代替他们。

 

 

 

 

条款二十八

避免返回一个内部成员的handle(引用或指针或迭代器)

 

当返回了一个成员的handle的时候,首先是你可能对一个函数返回值赋值,也可以通过一个const成员函数返回的引用来修改private成员的值。这一点通过定义const的返回值可以解决,但并不意味着就是安全的。

class Mem {
public:
	Mem() :mem(1), memp(&mem) {}
	int *memp;
	int mem;
};

class ConstReturn {
public:
	ConstReturn() :mem(){}
	const Mem &getMp() const
	{
		return mem;
	}
	~ConstReturn() { cout << "析构执行,此时*memp=" << *mem.memp << endl; }

private:
	Mem mem;
};

const ConstReturn tempConstReturn()//返回临时创建的对象
{
	return ConstReturn();
}

const Mem *a;
void test()
{
	a = &(tempConstReturn().getMp());//取一个临时对象的地址
	//其他
	cout << "创建语句后a:" << a->mem << endl;
}

int main()
{
	test();
	cout << "main函数中a:" << a->mem;
    //cout << "a->memp" << *a->memp << endl;//运行时错误,memp为空指针

	int z;
	cin >> z;
}

结果为

析构执行,此时*memp=1
创建语句后a:1
main函数中a:1

 

这里的*a指向一个无名的ConstResult的Mem成员,但事实上,这个无名的ConstResult对象在这句结束后就应该被摧毁(析构函数已经调用),也就意味着它的Mem成员也会一并摧毁,这让a成为了一个空悬指针(虽然实际上在单线程的编译运行时这段代码不会有任何问题)。

总之,返回一个类内部成员的handle是非常不明智的,应该尽量避免,即使返回也应该返回const引用来避免外部的修改。

 

其他:

关于析构函数,调用析构函数并不意味着对象就被摧毁了(特别当你显示调用的时候)。只是对象的那部分内存变为了可用的,析构函数其实还是像其他的成员函数一样,执行了里面的操作而已,当你使用delete的时候才是删除了内存。也就意味着,你使用析构函数后任然可以访问数据成员,单线程的话几乎不会出错,但是delete后则不行,可能是乱码也可能挂掉。

记住,析构函数执行之后才是内存回收(系统的默认删除法)。构造函数之前进行内存分配,构造函数体执行的时候,所有成员已经初始化完成。

 

 

 

 

条款二十九

保证函数的异常安全性

函数的异常安全性分三种

  1. 基本保证,保证异常发生后,类的结构,各种数据等会处于一个有效状态。比如替换图片时发生异常,那么就把原图片放回,或把某缺省图片放入,至于具体怎么做的,用户可能需要调用一个成员函数才知道。
  2. 强烈保证,保证异常发生后,数据保持发生前的状态,就像没调用这个函数一样。
  3. 无异常保证,函数根本不会抛出异常。意味着这个函数总是能完成它的工作。用于内置类型的所有函数操作都是nothrow保证的(内置指针、数值等)。
class Mutex {//测试用的Mutex
public:
	Mutex():locked(false) {  }
	~Mutex() {  }
	void lock() { locked = true; }
	void unlock() { locked = false; }

private:
	bool locked;
};

class Lock {//管理Mutex的RAII类
public:
	Lock():mutex() { mutex->lock(); }
	Lock(Mutex *mu) :mutex(mu) { mutex->lock(); }
	~Lock() { mutex->unlock(); }

private:
	Mutex *mutex;

};


struct Picture {
	
};

struct MenuBar {
	int changeTimes;
	shared_ptr<Picture> pic;
};

class Menu {
	//两个change函数强烈异常安全的前提是Picture的构造不会出现改变其他
	//状态的问题,假如两个函数的参数改为流对象,那么Picture构造失败时
	//很可能就改变了stream的读取位的状态,这样虽然Menu状态不变,但是
	//其他读取流的用户受到影响。
public:
	void changePicture(Picture *p)
	{
		Lock lock(&mutex);//使用对象管理Mutex
		pic.reset(new Picture(*p));
		//使用reset便不用再删除原对象,因为reset已经帮我们做了
		//并且在new创建的Picture成功前,是不会reset的

		changeTimes++;
	}//普通的强烈异常安全

	void copyAndSwapPicture(Picture *p)
	{
		using std::swap;
		Lock lock(&mutex);
		shared_ptr<MenuBar> newPic(new MenuBar(*mb));//复制原对象
		newPic->pic.reset(p);//改变副本对象
		newPic->changeTimes++;

		swap(mb, newPic);//交换原对象与副本
	}//使用copy and swap实现强烈异常安全,对象修改成功后才会交换

private:
	shared_ptr<Picture> pic;
	shared_ptr<MenuBar> mb;
	int changeTimes;
	Mutex mutex;
};
  1. 异常保证有三种类别
  2. 强烈保证并不是对所有的函数都有效/可行,毕竟需要复制对象(若使用swap and copy),这可能会带来非常大的消耗。
  3. 函数提供的异常安全保证强弱,只能等于函数调用的函数中最低的那个

条款三十

谨慎选择你的inline函数,只有真正频繁使用的函数才应该是inline函数。

inline函数通常一定在头文件内,因为inline函数在编译期就需要让编译器看到他的全部定义(为了把函数调用替换成函数本体),除非你的建制环境支持。

不要因为template函数定义在头文件中就把它定义成inline。对于模板函数,只有你相信所有的种类都需要inline时才定义成inline否则别这么做

对于inlin函数,他们会增加源码的大小,在每个inline调用的地方。但不是说inline每次调用都是inline的,函数指针调用inlin通常都不是inline另外所有virtual都阻止inline,因为virtual意味着运行时。

对于类,把构造/析构函数声明成inline是很糟糕的选择,因为它们之中包含:基类的构造,每个成员的构造,构造每个成员时还有相应的try{}catch{}语句保证一旦有异常抛出就会销毁之前构造的部分。

关于inline造成的影响,修改inline函数需要重新编译每个inline的位置,是非常大的开销;而如果这个函数不是inline,则只需要重新连接即可

 

 

 

 

条款三十一

让文件之间的编译依赖性最小

 

使得依赖依存性最小的原则是,让用户的使用依赖于声明函数/类的文件,而不是定义函数/类的文件,即提供仅有声明的头文件,再把实施放到另一个地方。使用Handle Classes或者Interface Classes来实现这一点

 

1、Handle Classes

声明一个返回实际类各个成员的Handle Classes在一个头文件中,这个头文件不需要包含实际类的头文件,只需要提供实际类的前置声明;然后在另一个头文件中同时包含这个Handle Class的头文件和实际类的头文件,并在此提供Handle Class的各个接口函数实现。

实际类Personimpl.h
#include<string>
struct PersonImpl
{
	PersonImpl(const std::string& n, int a) :name(n), age(a) {}
	~PersonImpl();
	std::string name;
	int age;
};//提供各个成员和构造,不需要是class,因为外部接口提供了封装

接口类Handle Class所处的personfwd.h
#include<string>
#include<memory>
//不包含实际类的头文件,所以实际类若更改,这里不需要编译,使用这个接口的客户也不用重新编译

struct PersonImpl;//前置声明,以便之后的声明使用
class Person
{
public:
	Person(const std::string &name, const int age);//提供一样的构造函数
	Person(std::shared_ptr<PersonImpl> p);
	~Person();
	std::string name();
	int age();//提供和实际类的成员完全一致的接口函数

private:
	std::shared_ptr<PersonImpl> pImpl;//使用智能指针防止内存泄漏
};

最后是实现接口的头文件Person.h
#include<string>
#include<memory>
#include"personfwd.h"
#include"PersonImpl.h"
//因为这里是具体的实现,故需要包含实际类的头文件

//调用相应的构造函数
Person::Person(const std::string &name, const int age) :pImpl(new PersonImpl(name, age)) {}

Person::Person(std::shared_ptr<PersonImpl> p) : pImpl(p) {}

Person::~Person() {}

//返回同名成员
std::string Person::name() { return pImpl->name; }

int Person::age() { return pImpl->age; }

Interface Class

使用除了析构函数以外全部都是虚函数的一个接口,以及相应的static工厂函数和继承来实现声明定义的分离。用户必须通过指针/引用来使用(事实上,出于内存安全的考虑,用户得用智能指针)。

接口类的头文件Person.h
#include<memory>
#include<string>


class Person {
public:
	virtual ~Person() {}//析构不能是纯虚,或是提供了定义的纯虚
	virtual std::string name() const = 0;
	virtual int age() const = 0;//成员接口
	static std::shared_ptr<Person> create(const std::string &name,int age);//工厂函数
};

实际类的头文件RealPerson.h
#include"Person_Interface.h"


class RealPerson : public Person//实现接口的函数
{
public:
	RealPerson(const std::string& n, int a) :pname(n), page(a) {}
	~RealPerson() override {};

	std::string name() const override { return pname; }
	
	int age() const override { return page; }

private:
	std::string pname;
	int page;
};

std::shared_ptr<Person> Person::create(const std::string &name, int age)
{
	std::shared_ptr<Person> p = std::make_shared<Person>(new RealPerson(name, age));
	return p;
}//提供了实际类定义后就可以实现接口程序
可见interface class返回的是智能指针,这强迫用户使用智能指针来防止内存泄漏。
使用方式:
shared_ptr<Person> p = Person::create("asdf",5);
//可以给Handle Class也定义一个类似的工厂函数在声明文件里,这样就也能使用智能指针
//保障自动释放,防止用户使用这个类的指针时忘记delete等

 

代价:

Handle Class

需要额外的空间,给implementetion pointer即指向实际类的shared_ptr,而且这也使得取得成员需要使用指针,增加了一层间接性(如果再添加工厂函数,那就是两层了!)。另外你必须初始化这个指针,那么就需要承受new内存不足的后果以及使用动态内存带来的额外开销。

Interface Class

首先,由于是继承体系,所以派生类需要一个vptr(虚指针),这可能会增加空间需求的数量(取决于除了interface class以外还有没有virtual函数);另外调用virtual函数本来就需要做出一个间接跳跃(indirect jump)的成本。

 

结论

能够使用object reference/pointers时就使用,尽量少用objects(对象),这一点是因为不需要知道类的具体定义就可以使用一个类的引用/指针,但具体类型则需要完整的定义。

为声明和定义类提供不同的头文件,让用户包含声明的头文件即可工作,防止改动类后需要大量的重新编译。也就是说,相比依赖定义,更好的是依赖声明。

猜你喜欢

转载自blog.csdn.net/qq_37051430/article/details/83154916