C++prime读书笔记(三)拷贝控制、运算重载与类型转换、对象继承、模板与泛型

6 篇文章 0 订阅
3 篇文章 0 订阅

layout: post
title: C++prime读书笔记(三)拷贝控制、运算重载与类型转换、对象继承、模板与泛型
description: C++prime读书笔记(三)拷贝控制、运算重载与类型转换、对象继承、模板与泛型
tag: 读书笔记


C++Prime读书笔记

第13章:拷贝控制

当定义一个类时,我们显式或隐式地指定在此类型的对象拷贝、移动、赋值和销毁时做什么,这些操作统称为拷贝控制操作,一个类通过定义5中特殊的成员函数来控制这些操作,包括拷贝构造函数、拷贝赋值函数、移动构造函数、移动赋值运算符和析构函数

拷贝、赋值与销毁

拷贝构造函数

如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数

class Foo
{
public:
	Foo();  // 默认构造
	Foo(const Foo&); // 拷贝构造
};

注意:
1、当我们没有为类声明任何构造函数时,编译器为我们合成默认构造函数。
但拷贝构造不同,即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数

2、拷贝构造接收的参数必须是自身类类型的引用,如果参数不是引用类型,则调用永远也不会成功,为了调用拷贝构造函数,我们必须拷贝它的实参,但为了拷贝实参,我们又要调用拷贝构造函数,如此无限循环。

拷贝初始化与直接初始化的区别

直接初始化时,我们实际上要求编译器使用普通的函数匹配,而使用拷贝初始化时,我们要求编译器将右侧运算对象拷贝到正在创建的对象,如果需要的话还会进行类型转换。

string dots(10, ','); //直接初始化
string s(dots);  // 直接初始化
string s2 = dots; // 拷贝初始化
string null-book = "99999=99999"; // 拷贝初始化
string nines = string(100, '9'); // 拷贝初始化

拷贝初始化不仅在使用=定义变量时会发生,在下列情况下也会发生:

  • 将一个对象作为实参传递给一个非引用类型的形参
  • 从一个返回类型为非引用类型的函数返回一个对象
  • 用花括号列表初始化一个数组中的元素或一个聚合类中的成员。

注: 1、某些类型还会对他们所分配的对象使用拷贝初始化,例如当我们使用insert或者push成员时,容器会对元素进行拷贝初始化,与之对应用emplace成员创建的元素都进行直接初始化
2、如果我们希望使用explicit构造函数就必须显式地使用直接初始化,不可使用隐式地拷贝初始化转换。

拷贝赋值运算符

与类控制对象如何初始化一样,类也可以控制对象如果进行赋值。
与处理拷贝构造函数一样,如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符。作为一个例子,下边的代码等价于Sales_data的合成拷贝赋值运算符,合成拷贝赋值运算符返回一个指向其左侧运算对象的引用。

Sales_data&
Sales_data::operator=(const Sales_data &rhs)
{
	bookNo = rhs.bookNo;
	units_sold = rhs.units_sold;
	revenue = rhs.revenue;
	return *this;
}

析构函数

析构函数释放对象使用的资源,销毁对象的非static数据成员。与普通指针不同,智能指针是类类型,所以具有析构函数。
以下情况会调用析构函数:

  • 变量在离开其作用域时被销毁
  • 当一个对象被销毁时,其成员被销毁
  • 容器被销毁时,其元素被销毁
  • 对于动态分配的对象,当指向它的指针应用delete运算符时被销毁。
  • 对于临时对象,当创建它的完整表达式结束时被销毁。

当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数,类似拷贝构造函数和拷贝赋值运算符,在(空)析构函数体执行完毕后,成员会被自动销毁,析构函数体自身并不直接销毁成员,成员是在析构函数体之后隐含的析构阶段中被销毁的,在整个对象销毁的过程中,析构函数体是作为成员销毁步骤之外的另一部分而进行的

三/五法则

C++中有三个基本操作可以控制类的拷贝操作:拷贝构造函数、拷贝赋值运算符和析构函数,在C++11新标准下,一个类还可以定义一个移动构造函数和一个移动赋值函数
当我们决定一个类是否要定义它自己版本的拷贝控制成员时,一个基本原则是首先确定这个类是否需要一个析构函数,通常对于析构函数的需求要比对拷贝构造函数或者赋值运算符的需求更加明显。如果这个类需要一个析构函数,我们几乎可以确定它也需要一个拷贝构造函数和一个拷贝赋值运算符

例如下边这个例子:HasPtr中有指针数据成员,因此需要析构函数来释放指针。另一方面,由于包含指针成员,如果使用合成的拷贝构造和拷贝赋值运算符,这些函数简单拷贝指针成员,意味着多个HasPtr对象可能指向相同的内存。

class HasPtr{
public:
	HasPtr(const std::string &s = std::string()): ps(new std::string(s)), i(0) {}
	~HasPtr(){delete ps;}
	// 错误:HasPtr还需要一个拷贝构造函数和一个拷贝赋值运算符

	HasPtr& operator=(const HasPtr &hasptr)
	{
		auto newps = new std::string(*(hasptr.ps)); // 拷贝底层string
		delete ps; // 释放旧内存
		ps = newps; // 赋值
		i = hasptr.i;
		return *this;
	} 
	
	HasPtr(const &hasptr)
	{
		ps = new std::string(*(hasptr.ps));
		i = hasptr.i;
		return *this;
	}
private:
	std::string *ps;
	int i;	
};

三/五法则的第二条是“如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个拷贝赋值运算符,反之亦然——如果一个类需要一个拷贝赋值运算符,几乎可以肯定它也需要一个拷贝构造函数。”
三/五法则补充:所有5个拷贝控制成员应该看做一个整体,一般来讲,如果一个类定义了任何一个拷贝操作,它就应该定义所有5个操作。这些类通常拥有一个资源,而拷贝成员必须拷贝此资源,一般来讲拷贝一个资源会导致一些额外的开销(拷贝完源对象后,源对象就不需要时),在这种拷贝并非必要的情况下,定义移动构造函数和移动赋值运算符就可以避免这种问题

使用=default

我们可以通过将拷贝控制成员定义为=default来显式地要求编译器生成合成的版本,当我们在类内使用=default修饰成员时,合成的函数将隐式地声明为内联的(就像任何其他类内声明的成员函数一样)。如果不希望合成的成员是内联函数,应该只对成员的类外定义使用=default,就像下面例子中对于拷贝赋值运算符=所做的那样。

class Sales_data
{
public:
	// 拷贝控制成员使用default
	Sales_data() = default;
	Sales_data(const Sales_data &) = default;
	Sales_data & operator= (const Sales_data &);
	~Sales_data() = default;
};
Sales_data& Sales_data::operator=(const Sales_data&) = default;

如果我们不希望合成的成员是内联函数,应该只对成员的类外定义使用=default。

阻止拷贝=delete

对于某些类来讲拷贝和赋值没有合理的意义,因此在定义这些类时必须采用某种机制阻止拷贝或赋值。例如iostream类阻止了拷贝,以避免多个对象写入或读取相同的IO缓冲。为了阻止拷贝,可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数,这种删除函数的意思是:我们虽然声明了它们,但不能以任何方式使用它们。=delete通知编译器,我们不希望定义这些成员。与=default不同,=delete必须出现在函数第一次声明时,且析构函数不能是删除的成员

struct NoCopy
{
	NoCopy() = default(); // 使用合成默认构造函数
	NoCopy(const NoCopy&) = delete; // 阻止拷贝
	NoCopy &operator=(const NoCopy&) = delete; // 阻止赋值
	~NoCopy() = default;  // 使用合成的析构函数
};

注:合成的拷贝控制成员可能是删除的,如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。

拷贝控制和资源管理

管理类外资源的类必须定义拷贝控制成员,一般来说有两种选择:可以定义拷贝操作,使得类的行为看起来像一个值或者像一个指针。

  • 类的行为像一个值,意味着它有自己的状态,当我们拷贝一个像值的对象时,副本和原对象是完全独立的,改变副本不会对原对象有任何影响,反之亦然。
  • 类的行为像一个指针,数据是共享状态,当我们拷贝一个像指针的对象时,副本和原对象共享底层数据。改变副本也会改变原对象,反之亦然。

在我们使用过的标准库类中,容器和string类的行为像一个值,而shared_ptr类提供类似指针的行为。IO类型和unique_ptr不允许拷贝或赋值,因此它们的行为既不像值也不像数据。

行为像值的类

下面定义了行为像值的HasPtr类,需要注意的是赋值运算符=的实现,赋值运算符通常组合了析构和构造函数的操作,赋值行为会先销毁=左侧的对象,然后将=右侧的值赋值给左侧。

class HasPtr{
public:
	HasPtr(const std::string &s = std::string()): ps(new std::string(s)), i(0) {}
	HasPtr(const HasPtr &p): ps(new std::string(*p.ps)), i(p.i) { }
	HasPtr& operator=(const HasPtr &);
	~HasPtr() {delete ps;}
private:
	std::string *ps;
	int i;	
};

HasPtr& :: HasPtr::operator=(const HasPtr &rhs)
{
	auto newp = new string(*rhs.ps); //拷贝底层string
	delete ps;  //释放旧内存
	ps = newp; //从右侧运算对象拷贝数据到本对象
	i = rhs.i;
	return *this;
}

行为像指针的类

对于行为类似指针的类,我们需要为其定义拷贝构造函数和拷贝赋值运算符,来拷贝指针成员本身而不是它指向的string(以上边的HasPtr为例)。令一个类展现类似指针行为的最好方法是使用shared_ptr来管理类中的资源。拷贝或赋值一个shared_ptr会拷贝或赋值shared_ptr所指向的指针。
当然为了厘清原理,我们也可以使用自己设计的引用计数

class HasPtr{
public:
	// 构造函数分配新的string和新的计数器,计数器置为1
	HasPtr(const std::string &s = std::string()) : ps(new std::string(s)), i(0), use(new std::size_t(1)) {}
	// 拷贝构造函数拷贝所有三个数据成员,递增计数器
	HasPtr(const HasPtr &p):ps(p.ps), i(p.i), use(p.use){++*use;}
	HasPtr& operator=(const HasPtr&);
	~HasPtr();
private:
	std::string *ps;
	int i;	
	std::size_t *use;
};
HasPtr::~HasPtr()
{
	//如果引用计数变为0则析构函数释放ps和use的内存
	if(--*use == 0){
		delete ps;
		delete use;
	}
}
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
++*rhs.use; //增加右侧运算对象的引用计数
if(--*use == 0) // 递减本对象的引用计数
{	
	delete ps;
	delete use;
}
ps = rhs.ps;
i = rhs.i;
use = rhs.use;
return *this;
}

交换操作

除了拷贝控制成员,资源管理类通常还会定义一个名为swap的函数。

class HasPtr{
	friend void swap(HasPtr&, HasPtr&);
};
inline
void swap(HasPtr &lhs, HasPtr &rhs)
{
	using std::swap;
	swap(lhs.ps, rhs.ps);
	swap(lhs.i, rhs.ps);
}

在赋值中使用swap

定义了swap可以通过拷贝并交换来实现它们的赋值运算符。

HasPtr& HasPtr::operator=(HasPtr rhs)
{
	swap(*this, rhs);
	return *this;
}

注意:这个版本的赋值运算符参数并不是const &,我们将右侧对象以传值方式传递给赋值运算符,rhs是右侧对象的一个副本,调用swap来交换rhs和*this中的数据成员,当赋值运算符结束时,这个副本rhs被销毁,HasPtr的析构函数将执行,此析构函数delete了rhs现在指向的内存,即释放掉左侧运算对象中原来的内存。这种拷贝并交换的技术自动处理了自赋值情况且天然就是异常安全的,因为它自动在赋值前拷贝了=右侧的副本,并通过swap赋值右侧对象的同时,保证了函数结束时,左侧对象被销毁。

拷贝控制示例

作为类需要拷贝控制操作的例子,下面设计两个类Message和Folder分别表示电子邮件消息和消息目录。

  • 1、每个message对象可以出现在多个folder中,但是任意给定的message的内容只有一个副本。这样,如果一条message的内容被改变,则我们从它所在的folder来浏览此message时都会看到改变后的内容。
  • 2、为了记录message位于哪些folder中,每个message都会保存一个他所在folder的指针的set,同样地,每个folder都保存一个它所包含的message的指针的set。
  • 3、message类提供save和remove操作来向一个给定folder添加一条message或是从中删除一条message,为了创建一个新的message,我们会指明消息内容,但不会指出folder,为了将一条message放到一个特定的folder中,我们必须调用save。
  • 4、当我们拷贝一个message时,副本和源对象将是不同的message对象,但两个message都出现在相同的folder中,因此拷贝message的操作包括消息内容和folder指针set的拷贝,而且我们必须在每个包含此消息的folder中都添加一个指向新创建message的指针。
  • 5、当我们销毁一个message时,它将不复存在,因此我们必须从包含此消息的所有folder中删除指向此message的指针
  • 6、当我们将一个message对象赋值给另一个message对象时,左侧message内容会被右侧message内容替换,我们必须还更新folder集合,从原来包含左侧message的folder中将它删除,并将它添加到包含右侧message的folder中。
class Message{
	friend class Folder;
public:
	explicit Message(const std::string &str = ""):contents(str){ }
	// 拷贝控制成员,用于管理指向本Message的指针
	Message(const Message&);
	Message& operator=(const Message&);
	~Message();
	// 从给定folder集合中添加/删除本message
	void save(Folder &);
	void remove(Folder &);
private:
	std::string contents; // 实际消息文本
	std::set<Folder*>folders;	// 包含本message的folder
	void add_to_Folders(const Message&);
	// 从folders中的每个Folder中删除本Message
	void remove_from_Folders();
}

void Message::save(Folder &f)
{
	folders.insert(&f);
	f.addMsg(this);
}

void Message::remove(Folder &f)
{	
	folders.erase(&f);
	f.remMsg(this);
}

void Message::add_to_Folders(const Message &m)
{
	for(auto f : m.folders)
		f->addMsg(this);
}

Message::Message(const Message &m):contents(m.contents), folders(m.folders)
{
	add_to_Folders(m);
}

void Message::remove_from_Folders()
{
	for(auto f:folders)
		f->remMsg(this);
}

Message::~Message()
{
	remove_from_Folders();
}

Message& Message::operator=(const Message &rhs)
{
	remove_from_Folders();
	contents = rhs.contents;
	folders = rhs.folders;
	add_to_Folders(rhs);
	return *this;
}

void swap(Message &lhs, Message &rhs)
{
	using std::swap;
	for (auto f:lhs.folders)
		f->remMsg(&lhs);
	for(auto f:rhs.folders)
		f->remMsg(&rhs);
	swap(lhs.folders, rhs.folders);
	swap(lhs.contents, rhs.contents);
	for(auto f:lhs.folders)
		f->addMsg(&lhs);
	for(auto f:rhs.folders)
		f->add(&rhs);			
}

动态内存管理类StrVec

实现标准库vector的一个简化版本,不使用模板,只用于string。
StrVec使用一个allocator来获得原始内存,由于allocator分配的内存是未构造的,我们需要添加新元素时使用allocator的construct成员在原始内存中创建对象,类似地,当需要删除一个元素时,我们将使用destroy成员来销毁元素。
每个StrVec有三个指针指向其元素所使用的内存:

  • elements,指向分配内存中的首元素
  • first_free,指向最后 一个实际元素之后的位置
  • cap,指向分配的内存末尾之后的位置
    除了指针,StrVec还有一个名为alloc的静态成员,类型为allocator< string >,还有4个工具函数:
  • alloc_n_copy会分配内存,并拷贝一个给定范围中的元素。
  • free会销毁构造的元素并释放内存
  • chk_n_alloc保证StrVec至少有容纳一个新元素的空间,如果没有空间添加新元素,chk_n_alloc会调用reallocate来分配更多内存
  • reallocate在内存用完时为StrVec分配新内存。
class StrVec{
public:
	StrVec():
	elements(nullptr), first_free(nullptr), cap(nullptr) { }
	StrVec(const StrVec&);
	StrVec &operator=(const StrVec&);
	~StrVec();
	void push_back(const std::string&);
	size_t size() const{return first_free - elements;}
	size_t capacity() const{return cap - elements;}
	std::string *begin() const{return elements;}
	std::string *end() const{return first_free;}
private:
	static std::allocator<std::string> alloc;
	void chk_n_alloc()
		{if (size() == capacity()) reallocate();}
	std::pair<std::string*, std::string*> alloc_n_copy(const std::string*, const std::string*);
	void free();
	void reallocate();
	std::string *elements;
	std::string *first_free;
	std::string *cap;	
};

void StrVec::push_back(const string& s)
{
	chk_n_alloc();
	alloc.construct(first_free++, s);
}

// alloc_n_copy利用尾后指针减去首指针计算需要多少空间,返回的pair的first指向分配内存开始的位置
//second成员是uninitialized_copy的返回值,指向最后一个构造元素之后的位置
pair<string*, string*>StrVec::alloc_n_copy(const string *b, const string *e){
	auto data = alloc.allocate(e-b);
	return {data, uninitialized_copy(b, e, data)};
}
// free成员有两个责任,首先destroy元素,然后释放StrVec直接分配的内存空间
void StrVec::free()
{
	//不能给deallocate传递空指针,如果elements为空则什么也不做
	if(elements)
	{
	// 逆序销毁旧元素
		for(auto p = first_free; p != elements;)
			alloc.destroy(--p);
		alloc.deallocate(elements, cap - elements);	
	}
}
//拷贝控制函数
StrVec::StrVec(const StrVec &s)
{
	auto newdata = alloc_n_copy(s.begin(), s.end());
	elements = newdata.first;
	first_free = cap = newdata.second;
} 
//析构函数调用free
StrVec::~StrVec(){free();}

//拷贝赋值运算符在释放已有元素之前调用alloc_n_copy,这样就可以正确处理自赋值问题
StrVec &StrVec::operator=(const StrVec &rhs)
{
	auto data = alloc_n_copy(rhs.begin(), rhs.end());
	free();
	elements = data.first;
	first_free = cap = data.second;
	return *this;
}

void StrVec::reallocate()
{
	auto newcapacity = size() ? 2 * size() : 1;
	auto newdata = alloc.allocate(newcapacity);
	auto dest = newdata;
	auto elem = elements;
	for(size_t i = 0; i != size(); ++i)
		alloc.construct(dest++, std::move(*elem++));
	free();
	elements = newdata;
	first_free = dest;
	cap = elements + newcapacity;	
}

对象移动

C++11一个最主要的特性是可以移动而非拷贝对象的能力,大多数情况下都需要使用对象拷贝,在其中的一些情况下,对象拷贝后就立即被销毁了,在这些情况下,移动而非拷贝对象会大幅度提示性能。
标准库容器,string和shared_ptr类既支持移动也支持拷贝,IO类和unique_ptr类可以移动但不能拷贝

右值引用

为支持移动操作,新标准引入了一种新的引用类型——右值引用。我们通过&&而非&获取右值引用。使用右值引用只能绑定到临时对象,所引用的对象将被销毁且没有其他用户,使用右值引用可以自由地接管所引用对象的资源。

标准库move函数

虽然不能将一个右值引用直接绑定到一个左值上,但我们可以显式地将一个左值转换为对应的右值引用类型,还可以通过调用一个名为move的新标准库函数(在头文件utility中)来获得绑定在左值上的右值引用。

int &&rr1 = 42;  // 正确,字面值是右值
int &&rr2 = rr1; // 错误,rr1是左值
int &&rr3 = std::move(rr1); // 正确,使用move获得了左值的右值引用,使用move的源值rr1将不能再使用

我们可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值

移动构造函数和移动赋值运算符

这两个成员类似对应的拷贝操作,但它们从给定对象“窃取”而不是拷贝资源。

StrVec::StrVec(StrVec &&s) noexcept : elements(s.elements), first_free(s.first_free), cap(s.cap) //移动操作不应抛出任何异常
{
	//令s进入这样的状态——对其运行析构函数是安全的
	s.elements = s.first_free = s.cap = nullptr;
}

noexcept通知标准库我们的构造函数不抛出任何异常,在确认操作不会抛出异常时应该通知编译器,否则它默认移动我们的类对象时可能抛出异常,并且为了处理这种可能性而作出一些额外的工作

Str &StrVec::operator=(StrVec &&rhs) noexcept
{
	// 直接检测自赋值
	if(this != &rhs)
	{
		free();
		elements = rhs.elements;
		first_free = rhs.first_free;
		cap = rhs.cap;
		rhs.elements = rhs.first_free = rhs.cap = nullptr;
	}
	return *this;
}

合成的移动操作,拷贝和移动匹配规则

与处理拷贝构造和拷贝赋值运算符相同,编译器也会合成移动构造函数和移动赋值运算符,但是合成移动操作的条件与合成拷贝操作的条件大不相同
与拷贝操作不同,编译器根本不会为某些类合成移动操作,特别是如果一个类定义了自己的拷贝构造函数、拷贝赋值函数或者析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符。如果一个类没有移动操作,通过正常的函数匹配,类会使用对应的拷贝操作代替移动操作,只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。编译器可以移动内置类型的成员,如果一个成员是类类型,且该类有对应的移动操作,编译器也能移动这个成员

struct X{
	int i;
	std::string s; // string定义了自己的移动操作	
};
struct hasX{
	X mem; // X有合成的移动操作
};
X x, x2 = std::move(x);
hasX, hx, hx2 = std::move(hx);

1、定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作,否则这些成员默认地被定义为删除的。
2、如果一个类既有移动构造函数也有拷贝构造函数,编译器按照普通函数匹配规则确定使用哪个构造函数,赋值操作也一样。如果接收参数是左值则是拷贝,如果是右值则匹配移动。移动右值,拷贝左值
3、如果一个类有拷贝构造但未定义移动构造,函数匹配规则保证该类型的对象会被拷贝,即使我们试图通过调用move来移动它们也是如此。但如果没有移动构造,右值也将被拷贝
4、用拷贝构造函数代替移动构造函数时,其对象通过拷贝构造函数来“移动”的,它会拷贝给定对象,并将原对象置于有效状态。

拷贝并交换赋值运算符和移动操作

HasPtr版本之前定义了一个拷贝并交换赋值运算符,它是函数匹配和移动操作间相互关系的一个很好的示例:

class HasPtr{
public:
	HasPtr(HasPtr &&p)noexcept : ps(p.ps), i(p.i) {p.ps = 0;}
	HasPtr& operator=(HasPtr rhs){swap(*this, rhs);return *this;}
};

赋值运算符接收一个非引用参数,这意味着此参数要进行拷贝初始化。依赖于实参的类型,拷贝初始化要么使用拷贝构造函数,要么使用移动构造函数——左值被拷贝,右值被移动,因此这种利用拷贝和交换技术的单一的赋值运算符就实现了拷贝赋值运算符和移动赋值运算符两种功能。

移动迭代器

C++11新标准库定义了移动迭代器,通过改变给定迭代器的解引用运算符的行为来适配此迭代器。移动迭代器的解引用运算符生成一个右值引用。
我们可以通过调用标准库中的make_move_iterator函数将一个普通迭代器转换为一个移动迭代器。
StrVec的reallocate成员使用for循环来调用construct从旧内存将元素拷贝到新内存中。

void StrVec::reallocate()
{
	auto newcapacity = size() ? 2 * size() : 1;
	auto first = alloc.allocate(newcapacity);
	auto last = uninitialized_copy(make_move_iterator(begin()), make_move_iterator(end()), first);
	free();
	elements = first;
	first_free = last;
	cap = elements + newcapacity;
}

右值引用与成员函数

处了构造与赋值运算符外,如果一个成员函数同时提供拷贝和移动版本,它也能从中受益。
以StrVec的push_back为例:

class StrVec{
public:
	void push_back(const std::string&); // 拷贝元素
	void push_back(std::string&&); // 移动元素
};
void StrVec::push_back(const string& s){
	chk_n_alloc();
	alloc.construct(first_free++, s);
}
void StrVec::push_back(string &&s)
{
	chk_n_alloc();
	alloc.construct(first_free++, std::move(s));
}

当调用push_back时,实参类型决定了新元素是拷贝还是移动到容器中:

StrVec vec;
string s = "www.";
vec.push_back(s);// 拷贝
vec.push_back("com");  // 移动

引用限定符、重载和引用函数

通常我们在一个对象上调用成员函数而不管该对象是一个左值还是右值:

string s1 = "a value", s2 = "another";
auto n = (s1 + s2).find('a');

上例中我们在一个string右值上调用find成员。有时右值的使用方式可能是不适宜的,例如下边,我们对于一个右值进行了赋值。

s1 + s2 = "wow";

在旧标准中我们没法阻止这种使用方式,新标准为了维持向后兼容性,仍允许向右值赋值,如果我们希望在自己的类中阻止这种用法,可以在参数列表后放置引用限定符直接指明this的左值/右值属性。引用限定符可以是&,也可以是&&,分别指出this可以指向一个左值或右值。类似const限定符,引用限定符只能用于(非static)成员函数,且必须同时出现在函数的声明和定义中,如果一个函数同时需要const和引用限定,这种情况下,引用限定符必须跟随在const限定符之后。

class Foo{
public:
	Foo &operator=(const Foo&) &;  // 限定this为左值,只能向可修改的左值赋值
};
Foo &Foo::operator=(const Foo &rhs) &
{
	// 指向将rhs赋予本对象所需工作
	return *this;
}

就像成员函数可以根据是否有const来区分其重载版本一样,引用限定符也可以区分重载版本。而且我们可以综合引用限定符和const来区分一个成员函数的重载版本。

class Foo
{
public:
	Foo sorted() &&;
	Foo sorted() const &;
private:
	vector<int> data;		
};

// 本对象为右值,因此可以原址排序
Foo Foo::sorted() &&
{
	sort(data.begin(), data.end());
	return *this;
}

// 本对象是const或是一个左值,哪种情况下我们都不能进行原址排序
Foo Foo::sorted() const &{
	Foo ret(*this); // 拷贝一个副本
	sort(ret.data.begin(), ret.data.end()); // 排序副本
	return ret; // 返回副本 
}

如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符

第14章:重载运算与类型转换

输入和输出运算符

输出运算符 <<

ostream &operator<<(ostream &os, const Sales_data &item)
{
	os << item.isbn() << " " << item.units_sold << " "
	   << item.revenue << " " << item.avg_price();
	return os;   
}

1、通常,输出运算符应该主要负责打印对象内容,而非控制格式
2、与iostream标准库兼容的输入输出运算符必须是普通的非成员函数,而不能是类的成员函数。当然IO运算符通常需要读写类的非公有数据成员,所有IO运算符一般被声明为友元

输入运算符>>

istream &operator>>(istream &is, Sales_data &item)
{
	double price;
	is >> item.bookNo >> item.unit_sold >> price;
	if(is) // 检测输入是否成功
		item.revenue = item.unit_sold*price;
	else
		item = Sales_data();// 输入失败,对象被赋予默认状态
	return is;		
}

算术和关系运算符

相等运算符

相等运算符== 与!=

bool operator==(const Sales_data &lhs, const Sales_data &rhs)
{
	return lhs.isbn() == rhs.isbn() && lhs.units_sold == rhs.units_sold && lhs.revenue == rhs.revenue;
}

bool operator!=(const Sales_data &lhs, const Sales_data &rhs)
{
	return !(lhs == rhs);
}

赋值运算符

除了拷贝赋值和移动赋值运算符,类还可以定义其他赋值运算符,例如标准库vector类还定义了第三种赋值运算符,接受花括号内元素列表作为参数。把这种赋值运算符添加到StrVec中:

class StrVec{
public:
	StrVec &operator=(std::initializer_list<std::string>);
};

StrVec &StrVec::operator=(initializer_list<string> il)
{
	auto data = alloc_n_copy(il.begin(), il.end());
	free();
	elements = data.first;
	first_free = cap = data.second;
	return *this;
}

与拷贝赋值及移动赋值运算符一样,其他重载的赋值运算符也必须先释放当前的内存空间,再创造一片新空间,不同的是该运算符无需检查自赋值。

复合赋值运算符
复合赋值运算符不非得是类成员,不过我们倾向于将其定义在类的内部。

Sales_data & Sales_data::operator+=(const Sales_data &rhs)
{
	units_sold += rhs.units_sold;
	revenue += rhs.revenue;
	return *this;
}

下标运算符

下标运算符必须是成员函数
为了与下标的原始定义兼容,下标运算符通常以所访问元素的引用作为返回值,这样做的好处是下标可以出现在赋值运算符的任意一端。进一步,我们最好同时定义下标运算符的常量版本和非常量版本,当作用于一个常量对象时,下标运算符返回常量引用以确保我们不会给返回的对象赋值。

class StrVec{
public:
	std::string& operator[](std::size_t n){return elements[n];}
	const std::string& operator[](std::size_t n) const {return elements[n];}
private:
	std::string *elements;	
};

递增和递减运算符

要想同时定义前置和后置运算符,必须首先解决一个问题,即普通的重载形式,无法区分前置和后置。为了区分前置运算符和后置运算符,后置版本接受一个额外的、不被使用的int类型的形参,它仅仅是为了区分前置和后置。

class StrBlobPtr{
public:
	// 递增和递减运算符
	StrBlobPtr& operator++; // 前置
	StrBlobPtr& operator--;
	
	// 递增和递减运算符
	StrBlobPtr& operator++(int); // 后置
	StrBlobPtr& operator--(int);
};
StrBlobPtr&  StrBlobPtr::operator++()
{
check(curr, "increment past end of StrBlobPtr");
++curr;
return *this;
}
StrBlobPtr&  StrBlobPtr::operator--()
{
--curr;
check(curr, "increment past end of StrBlobPtr");
return *this;
}
StrBlobPtr StrBlobPtr::operator++(int)
{
	StrBlobPtr ret = *this;
	++*this;
	return ret;
}
StrBlobPtr StrBlobPtr::operator--(int)
{
	StrBlobPtr ret = *this;
	--*this;
	return ret;
}

成员访问运算符

在迭代器类及智能指针类中常常用到解引用运算符(*)和箭头运算符(->)

class StrBlobPtr
{
public:
	std::string& operator*() const
	{
	auto p = check(curr, "dereference past end");
		return (*p)[curr];
	}
	std::string& operator->() const
	{
		// 将实际工作委托给解引用运算符
		return & this->operator*()
	}
}

函数调用运算符

如果类定义了调用运算符,则类对象称为函数对象。

class PrintString{
public:
	PrintString(ostream &o = cout, char c = ' '):os(o), sep(c){ }
	void operator()(const string &s) const {os << s << sep;}
private:
	ostream &os;
	char sep;	
};

PrintString printer;
printer(s);  // 在cout中打印s后边跟一个空格
PrintString errors(cerr, '\n');
errors(s);  // 在ceer中打印s,后边跟一个换行符

函数对象常常作为泛型算法的实参,例如可以利用标准库的for_each算法和我们自己的PrintString类来打印容器的内容;

lambda是函数对象

下边例子中的lambda的行为可以是ShorterString()表示的函数对象。

stable_sort(words.begin(), words.end(), [](const string &a, const string &b){return a.size() < b.size();});

class ShorterString{
public:
	bool operator()(const string &s1, const string &s2) const
	{return s1.size() < s2.size();}
};

stable_sort(words.begin(), words.end(), ShorterString());

下面再举一个有捕获参数的lambda:

auto wc = find_if(words.begin(), words.end(), [sz](const string &a){return a.size() >= sz;});

// 该lambda表达式产生的类将形如:
class SizeComp{
SizeComp(size_t n):sz(n){ }
bool operator()(const string &s) const {return s.size() >= sz;}
private:
	size_t sz;
};

auto wc = find_if(words.begin(), words.end(),SizeComp(sz));

lambda表达式产生类不含默认构造函数,赋值运算符及默认析构函数;它是否含有默认拷贝/移动构造函数通常要视捕获的数据成员而定。

标准库定义的函数对象

标准库定义了一组表示算术运算符、关系运算符、逻辑运算符的类,每个类分别定义了一个执行命名操作的调用运算符,例如plus类定义了一个函数调用符用于对于一对运算对象执行+的操作,equal_to类执行==

plus<int> intAdd;  //可执行int加法的函数对象
negate<int> intNegate;  //可执行int值取反的函数对象
int sum = intAdd(10, 20);  // 等价于sum = 30
sum = intNegate(intAdd(10, 20));
sum = intAdd(10, intNegate(10));  // sum = 0

可调用对象与function

C++语言中有几种可调用对象:函数、函数指针、lambda表达式、bind创建的对象以及重载了函数调用运算符的类。不同类型的可调用对象可能共享同一种调用形式,调用形式指明了返回的类型以及传递给调用的实参类型,一种调用形式对应一个函数类型,例如:

int (int, int)

是一个函数类型,接受两int,返回一个int

不同的类型可能具有相同的调用形式:

int add(int i, int j){return i + j;}
auto mod = [](int i, int j){return i % j};
struct divide{
	int operator()(int denominator, int divisor){
		return denominator / divisor;
	}
};

上边三个可调用对象分别对参数执行了不同的算术运算,尽管它们类型不同(普通函数、lambda、函数对象)但是共享同一种调用形式:int(int, int)。我们可能希望使用这些可调用对象构建一个简单的桌面计算器,为实现这一目的,我们需要定义一个函数表用于存储这些可调用对象的“指针”,函数表使用map实现:

map<string, int(*)(int, int)>binops;
binops.insert({"+", add});

但是mod或者divide无法存入binops,因为mod和divide不是函数指针。
为解决上述问题,新标准库引入了function类型,function是一个模板,它是用来存储可调用对象的空function,这些可调用对象的调用形式应该与函数类型T相同。
使用function类型我们可以重新定义map:

map<string, function<int(int, int)>> binops;
map<string, function<int(int, int)>> binops = {
	{"+", add},
	{"-", std::minus<int>()},
	{"/", divide()},
	{"*", [](int i, int j){return i*j}},
	{"%",mod}
};

binops["+"](10,5); // 调用add(10,5)
binops["-"](10,5); // 调用调用标准库中的minus<int>对象的调用运算符
binops["*"](10,5); // 调用lambda对象
binops["%"](10,5); // 调用lambda对象
binops["/"](10,5); // 调用divide对象的调用运算符(10,5)

我们不能直接将重载的函数的名字存入function类型的对象中:

int add(int i, int j){return i + j;}
Sales_data add(const Sales_data&, const Sales_data&);
map<string, function<int(int, int)>> binops;

如果直接binops.insert({"+", add});就会出现二义性,不知道add到底是哪个?
解决方法是存储函数指针而非函数的名字:

int (*fp)(int, int) = add; // 指针所指的add是接受两个int的版本
binops.insert({"+", fp});

重载、类型转换与运算符

类型转换运算符

类型转换运算符是类的一种特殊成员函数,它负责将一个类的类型转换为其他类型,类型转换函数的一般形式如下:

operator type() const;

其中,type表示转换目标类型,除了void、数组或函数类型,但允许转换成指针(包括数组指针及函数指针)。类型转换运算符既没有显式的返回类型,也没有形参,而且必须定义为类的成员函数。类型转换运算符通常不应该改变待转换对象的内容,因此,类型转换运算符一般被定义成const成员。

下边的这个例子,构造函数可以将算术类型的值i,转为该类的size_t类型,而通过类型转换符的定义,又可以将size_t转为int。

class SmallInt{
public:
	SmallInt(int i = 0):val(i)
	{
		if(i < 0 || i > 255)
			throw std::out_of_range("Bad SmallInt value");
	}
	operator int() const{return val;}
private:
	std::size_t val;	
};

SmallInt si;
si = 4; // 4隐式转为smallInt,然后赋值
si + 3; // 首先si隐式转为int,随后执行加法

SmallInt si = 3.14; // 调用SmallInt(int)构造函数,内置类型将double实参转为int
si + 3.14; // 内置类型转为int,继而转为double

因为类型转换运算符是隐式执行的,所以无法给这些函数传递实参,当然也就不能在类型转换法的定义中使用任何形参,同时尽管类型转换函数体不负责指定返回类型,但实际上每个类型转换函数都会返回一个对应类型的值。

class SmallInt;
operator int(SmallInt&);  // 错误:不是成员函数
class SmallInt{
public:
	int operator int() const; // 错误:指定了返回类型
	operator int(int = 0) const; // 错误:参数列表不为空
	operator int*() const{return 42;} // 错误:42不是一个指针
}

为了防止隐式类型转换可能存在的问题,C++11标准引入了显示类型转换运算符(explicit conversion operator)

class SmallInt
{
public:
	explicit operator int() const{return val;}
	// 其他成员和之前版本一致
};

SmallInt si = 3; // 正确:构造函数不是显式的,因此3可以隐式转为smallInt再赋值
si + 3; // 错误,si不再能隐式转为int
static_cast<int>(si) + 3; // 正确:显式地请求类型转换

当类型转换符是显式的时候,我们也能执行类型转换,不过必须通过显式的强制类型转换才可以。该规定存在一个例外,当表达式出现在以下位置时,显式的类型转换将被隐式地执行:

  • if、while、do语句的条件部分
  • for语句的条件表达式
  • 逻辑与或非的运算对象
  • 条件运算符(?:)的条件表达式

第15章:面向对象程序设计

面向对象设计基于三个基本概念:封装、继承和多态。

面向对象程序设计的核心思想是数据抽象、继承和动态绑定。

  • 继承:通常在层次关系的根部有一个基类,其他类直接或间接由基类继承而来。称为派生类。C++语言中基类将类型相关的函数与派生类不做改变直接继承的函数区分对待,对于某些函数,基类希望它的派生类各自定义适合自身版本的函数,此时基类就将这些函数声明为虚函数,在声明前加关键字virtual。派生类必须通过类派生列表明确指出它是从哪个(哪些)基类继承而来,类派生列表的形式是:首先一个冒号,后边紧跟着以逗号分隔的基类列表,每个基类前面可以有访问说明符(public、private、protected)。C++11标准允许派生类显式地注明它使用哪个成员函数改写基类的虚函数,具体措施是在该形参列表后增加一个override关键字。
    下边的例子派生类Bulk_quote公共继承基类Quote,基类定义有一个虚函数net_price(),派生类需要override它。
class Quote{
public:
	std::string isbn() const;
	virtual double net_price(std::size_t n) const;
};
class Bulk_quote : public Quote{
public:
	double net_price(std::size_t n) const override;
};
  • 动态绑定:即运行时绑定,在函数运行时,选择使用函数的版本。动态绑定允许程序在运行时,根据形参是基类或派生类,来选择使用基类的函数或派生类的函数。
    例如下面的例子:函数形参中包含的参数Quote类型的一个引用,由于Quote的派生类Bulk_quote也可称之为Quote类,因此,我们既可以使用基类Quote对象调用该函数,也可以使用派生类Bulk_quote的对象调用它,实际传入print_total的Quote类型将决定,函数是调用基类的Quote::net_price()还是调用派生类Bulk_quote::net_price()。
double print_total(ostream &os, const Quote &item, size_t n)
{
	// 根据传入的形参对象类型调用Quote::net_price
	// 或者Bulk_quote::net_price
	double ret = item.net_price(n);
	os << "ISBN:" << item.isbn() << " # sold:" << n << " total due:" << ret << endl;
	return ret;
}
Quote basic = Quote();
Bulk_quote bulk = Bulk_quote(); 
print_total(cout, basic, 20); // 调用Quote的net_price()
print_total(cout, bulk , 20); // 调用Bulk_quote的net_price()

定义基类和派生类

定义基类

  • 基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此。
    虚析构的作用在于:
    当父类的析构函数不声明成虚析构函数的时候,当子类继承父类,父类的指针指向子类时,delete掉父类的指针,只调动父类的析构函数,而不调动子类的析构函数。 如果子类有对象开辟到堆区,那么只调用父类析构函数将无法释放子类开辟到堆区的空间,造成内存泄露。
    而当父类的析构函数声明成虚析构函数的时候,当子类继承父类,父类的指针指向子类时,delete掉父类的指针时,会先调动子类的析构函数,再调动父类的析构函数。
  • 基类将析构函数定义为虚函数,而对应的构造函数则不能为虚函数,这是因为:
    ①从存储空间角度

虚函数对应一个vtable,vtable是存储在对象的内存空间的。问题出来了,如果构造函数是虚的,就需要通过 vtable来调用,可是对象还没有实例化,也就是内存空间还没有,无法找到vtable,所以构造函数不能是虚函数。
②从使用角度
虚函数的作用在于通过父类的指针或者引用来调用它的时候能够变成调用子类的那个成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类的指针或者引用去调用,因此也就规定构造函数不能是虚函数。

  • 基类通过在其成员函数声明语句前加关键字virtual使得该函数可以执行动态绑定。任何构造函数之外的非静态函数都可以是虚函数,关键字virtual只能出现在类内部的声明语句之前,而不能用于类外部的函数定义,如果一个基类把一个函数声明为虚函数,则该函数在派生类中隐式地也是虚函数。
  • 成员函数如果没被声明为虚函数,则其解析过程发生在编译时而非运行时。
  • 和其他使用基类的代码一样,派生类能访问公有成员,而不能访问私有成员,如果基类希望派生类访问它的某个成员而禁止其他用户访问,则可以用关键字protected 声明这样的成员。
    关于访问权限:
    在这里插入图片描述
    public 类外类内都可访问
    protected 类外不可访问,类内可以
    private 类外不可访问,类内可以
    通过public继承父类中public和protected
    通过protected继承,父类中的public和protected都变为了protected
    通过private继承,父类中的public和protected都变为了private

定义派生类

  • 一个派生类对象包含多个组成部分:一个含有派生类自己定义的(非静态)成员的子对象,以及一个与该派生类继承的基类对于的子对象,如果继承自多个基类,则这样的子对象也有多个。
  • 因为派生类中含有与其基类对应的组成部分,所以我们能把派生类的对象当成基类对象来使用,而且我们也能将基类的指针或引用绑定到派生类对象中的基类部分上。
Quote item;
Bulk_quote bulk;
Quote *p = &item; // p指向Quote对象
p = &bulk; // p指向bulk的Quote部分
Quote &r = bulk;  // r绑定到bulk的Quote部分

这种转换通常称为派生类到基类的类型转换,编译器会隐式地执行派生类到基类的转换

  • 尽管派生类对象中含有从基类继承而来的成员,但是派生类并不能直接初始化这些成员,和其他创建了基类对象的代码一样,派生类也必须使用基类的构造函数来初始化它的基类部分
  • 派生类构造函数同样是通过构造函数初始化成员列表将实参传递给基类的构造函数的,首先初始化基类部分,然后按照声明的顺序依次初始化派生类的成员。
  • 如果基类定义了一个静态成员,则在整个继承体系中,只存在该成员的唯一定义,不论从基类派生出多少个派生类,对于每个静态成员来说都只有唯一的实例。
  • 有时我们会定义这样一种类,我们不希望其他类继承它,或者不想考虑它是否适合作为一个基类,为了实现这一目的,C++11标准提供了一种防止继承的方法,即在类后边加关键字final

类型转换与继承

通常情况下,如果我们想把引用或者指针绑定到一个对象上,则引用或指针的类型应该与对象的类型一致,或者对象的类型含有一个可接受const类型转换规则。存在继承关系的类是一个重要的例外,我们可以将基类的指针或引用绑定到派生类对象上,例如我们可以用Quote* 指向一个Bulk_quote对象,也可以把一个Bulk_quote对象的地址赋给一个Quote*。

  • 和内置指针一样,智能指针类也支持派生类向基类的类型转换,这意味着我们可以将一个派生类的对象的指针存储在一个基类的智能指针内。
  • 派生类向基类的自动类型转换只针对引用或指针类型有效。
  • 之所以存在派生类向基类的类型转换是因为派生类本身包含一个基类部分,反之则不行,故不存在从基类向派生类的类型转换。

虚函数

C++使用基类的引用或指针调用一个虚成员函数时会执行动态绑定,对虚函数的调用可能在运行时才被解析。

  • 当我们在派生类覆盖了某个虚函数时,可以再次使用virtual关键字指出该函数的性质。然而并非必须这样,因为一旦某个函数被声明为虚函数,则在所有的派生类中它都隐式地被声明为虚函数。
  • C++11标准中我们可以使用关键字override来说明派生类中的虚函数,表示基类中的虚函数将被该函数覆盖。假如派生类中定义了一个与基类函数同名但参数列表不同的函数,这仍然是合法行为,但编译器会认定该函数和基类中的虚函数是相互独立的两个函数,不会覆盖基类中的函数。使用override显然能够将程序员的操作意图表达的更加清晰。
  • 我们还能将某个成员函数指定为final,如果成员函数被定义为final,则之后任何尝试覆盖该函数的操作都将引发错误:
struct B{
	virtual void f1(int) const;
	virtual void f2();
	void f3();
};
struct D1:B{
		void f1(int) const override;  // 正确,覆盖B中的f1
		void f2(int) override;  // 错误,B没有形如f2(int)的函数
		void f3() override;  // 错误,只有虚函数可以被覆盖
		void f4() override; // 错误,B中没有f4函数
};
struct D2:B{
	void f1(int) const final; // 不允许后续其他类覆盖f1(int)
};
struct D3:D2{
	void f2();  // 正确,覆盖从间接基类B继承而来的f2
	void f1(int) const;  // 错误:D2已经将f1int()声明为了final
};
  • 如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致。
  • 某些情况下,我们希望回避虚函数,即希望虚函数调用不进行动态绑定,而是强迫其执行虚函数的某个特定版本。使用作用域运算符可以实现这一目的:
double undicounted = baseP->Quote::net_price(42);

上面的调用强行使用Quote 的net_price()函数,而不管baseP实际指向的对象类型到底是什么,该调用在编译时完成解析,即不会动态绑定。

抽象基类

在多态中,通常父类中虚函数的实现是毫无意义的,主要都是调用子类重写的内容

因此可以将虚函数改为纯虚函数,纯虚函数只需要声明无需实际定义。

纯虚函数语法virtual 返回值类型 函数名 (参数列表) = 0
当类中有了纯虚函数,这个类也称为抽象类,即含有纯虚函数的类是抽象基类
抽象基类负责定义接口,而后续的其他类可以覆盖该接口。

抽象类的特点

  • 无法实例化对象,只是作为继承使用
  • 子类必须重写抽象类中的纯虚函数,否则也属于抽象类

抽象类使用案例——制作饮品

class AbstractDring
{
public:
	// 煮水
	virtual void Boil() = 0;
	// 冲泡
	virtual void Brew() = 0;
	// 倒入杯中
	virtual void PourInCup() = 0;
	// 加入辅料
	virtual void PutSomething() = 0;
	//制作饮品
	void makeDrink()
	{
		Boil();
		Brew();
		PoutInCup();
		PutSomething();
	}
};
// 制作咖啡
class Coffee:public AbstractDrinking
{
public:
	// 煮水
	virtual void Boil() 
	{
	cout <<"煮水" << endl;
	};
	// 冲泡
	virtual void Brew()
	{
		cout <<"冲泡咖啡" << endl;
	};
	// 倒入杯中
	virtual void PourInCup()
		{
		cout <<"倒入杯中" << endl;
	};
	// 加入辅料
	virtual void PutSomething()
	{
	cout <<"加入糖和牛奶" << endl;
	};
};

// 制作茶叶
class Tea:public AbstractDrinking
{
public:
	// 煮水
	virtual void Boil() 
	{
	cout <<"煮水" << endl;
	};
	// 冲泡
	virtual void Brew()
	{
		cout <<"冲泡茶叶" << endl;
	};
	// 倒入杯中
	virtual void PourInCup()
		{
		cout <<"倒入杯中" << endl;
	};
	// 加入辅料
	virtual void PutSomething()
	{
	cout <<"加入蜂蜜" << endl;
	};
};
// 制作咖啡
void doWork(AbstractDrinking *abs)
{
	abs->makeDrink();
	delete abs; // 释放内存
}
void test01()
{
	// 制作咖啡
	doWork(new Coffee);
	cout << "----------" << endl;
		// 制作茶叶
	doWork(new Tea);
}

访问控制与继承

每个类分别控制自己的成员初始化过程,与之类似,每个类还分别控制着其成员对于派生类是否可以访问。

protected保护成员

protected说明符和私有成员类似,对于类的用户不可访问,但对派生类的成员和友元来说是可以访问的。但需要注意的是:派生类的成员和友元只能访问派生类对象中的基类部分的protected成员,而不对普通基类对象有特殊访问权限。

class Base{
protected:
	int prot_mem;
};
class Sneaky:public Base{
	friend void clobber(Sneaky&);  //能访问Sneaky对象基类部分的prot_mem
	friend void clobber(Base&);  // 不能访问Base::prot_mem
	int j;
};
void clobber(Sneaky &s){
	s.j = s.prot_mem = 0;  // 正确,clobber能访问Sneaky对象的private和protected成员
}
void clobber(Base &b){
	b.prot_mem = 0; // 错误,clobber不能访问Base的protected成员
}

访问权限与继承

  • 访问权限由类中的权限声明符和继承时的访问权限说明共同决定,继承关系会使得派生类所继承的基类成员中保护等级低于继承时使用的访问说明符的保护等级低的成员的保护等级,提升至继承访问说明符的保护等级。(保护等级:private > protected > public,继承只会提升成员保护等级,不会降低成员保护等级)
    在这里插入图片描述
  • 就像友元关系不能传递一样,友元关系也不能继承。
  • 有时我们需要改变派生类继承的某个名字的访问级别,通过使用using声明。下边这个例子派生类Derived使用私有继承,因此继承而来的size()和n是私有成员,然而通过使用using声明,将其私有访问改变为了公共访问和保护访问。using声明的访问权限由它所属的位置部分有关,例如size属于public下,因此被改为public,而n属于protected,因此被改变为protected。
class Base{
public:
	std::size_t size() const {return n;}
protected:
	std::size_t n;	
};
class Derived:private Base{
public:
	using Base::size;
protected:
	using Base::n;	
};

继承中的类作用域

  • 派生类的作用域嵌套在其基类的作用域之内:每个类定义自己的作用域,在这个作用域内我们定义类的成员。当存在继承关系时,如果一个名字在派生类的作用域内无法解析,则编译器将继续在外层的基类作用域中寻找该名字的定义。
  • 派生类成员将隐藏同名的基类成员:派生类也能重用定义在其直接基类或者间接基类中的名字,此时定义在内层作用域(即派生类作用域)的名字将隐藏定义在外层作用域(即基类作用域)的名字。
  • 可以通过使用作用域运算符来使用一个被隐藏的基类成员。但是仍然建议,除了覆盖继承而来的虚函数之外,派生类不要重用其他定义在基类中的名字。

继承关系中名字查找的顺序

理解函数调用解析过程对理解C++的继承至关重要,假定我们调用p->mem(),则依次执行以下4个步骤:

  1. 首先确定p的静态类型,如果我们要调用的是一个成员,则该类型必须是类的类型。
  2. 在p的静态类型对应的类中查找mem。如果找不到,则依次在直接基类中不断查找,直至到达继承链的顶端。如果找遍了该类及基类仍然找不到,则编译器报错。
  3. 一旦找到了mem,则进行常规的类型检查,以确认对于找到的mem,本次调用是否合法。
  4. 假设调用合法,则编译器将根据调用的是否是虚函数而产生不同的代码。
    如果mem是虚函数且我们通过引用或指针进行的调用,则编译器产生的代码将在运行时确认到底运行该虚函数的哪个版本,依据的是对象的动态类型。
    反之,如果mem不是虚函数或者我们是通过对象(而非指针或者引用)进行的调用,则编译器将产生一个常规函数的调用。
    注意:名字查找优先于类型检查,名字查找从内层作用域向外延展
    如果派生类的成员与基类某个成员同名,则派生类将在其作用域内隐藏该基类成员,即便派生类成员和基类成员的形参列表不一致,基类的同名成员仍然会被隐藏掉。

通过上边的函数执行解析过程,就可以理解,为什么基类与派生类中的虚函数必须有相同的形参列表。假如基类与派生类的虚函数接受的实参不同,则我们就无法通过基类的引用或者指针调用派生类的虚函数了。

构造函数与拷贝控制

位于继承体系中的类也需要控制当其对象执行创建、拷贝、移动、赋值和销毁时的行为,需要定义拷贝控制操作。

  1. 合成拷贝控制与继承
    合成拷贝控制成员对类本身的成员依次进行初始化、赋值或销毁的操作,此外这些合成的成员还负责使用直接基类中对应的操作对一个对象的直接基类部分进行初始化、赋值或销毁的操作,例如:

    • 合成的Bulk_quote默认构造函数运行Disc_quote的默认构造函数,后者又运行Quote的默认构造函数。
    • Quote的默认构造将bookNo成员默认初始化为空字符串,同时使用类内初始值将price初始化为0。
    • Quote的构造函数完成后,继续执行Disc_quote的构造函数,它使用类内初始化值qty和discount。
    • Disc_quote的构造函数完成后,继续执行Bulk_quote的构造函数,但是它什么具体工作也不做。
  2. 派生类中删除的拷贝控制与基类的关系

    • 基类需要虚析构这一事实,会对基类和派生类的定义产生一个间接影响,即如果一个类定义了析构函数,即使它通过=default的形式使用了合成的版本,编译器也不会为这个类合成移动操作。
    • 如果基类中的默认构造、拷贝构造、拷贝赋值运算符或析构函数是被删除的函数(=delete)或不可访问,则派生类中对应的成员将是被删除的,原因是编译器不能使用基类成员来执行派生类对象的基类部分的构造、赋值或销毁
    • 如果基类中有一个被删除的或不可访问的析构函数,则派生类中合成的默认构造和拷贝构造函数将是被删除的,因为编译器无法销毁派生类的基类部分,所有将其设置为不可构造。
    • 当我们使用=default请求一个移动操作时,如果基类中对应的操作是删除的或不可访问,则派生类的该函数也将是删除的,因为派生类的基类部分不可移动。
  3. 移动操作与继承
    如前所述,大多数基类会定义虚析构函数,因此默认情况下,基类通常不含有合成的移动操作,故派生类中也没有合成的移动操作。
    因为基类中缺少移动操作会阻止派生类拥有自己的合成移动操作,当我们确实需要执行移动操作时,应该首先在基类中进行定义。一旦Quote定义了自己的移动操作,那么它必须显式地定义拷贝操作。

class Quote{
public:
	Quote() = default;  // 默认构造
	Quote(const Quote&) = default;  // 默认拷贝构造
	Quote(Quote&&) = default; // 默认移动构造
	Quote& operator=(const Quote&) = default; // 拷贝赋值
	Quote& operator=(Quote&&) = default;// 移动赋值
	virtual ~Quote() = default;
};

派生类的拷贝控制成员

  • 派生类构造函数在其初始化阶段,不但要初始化派生类自己的成员,还负责初始化派生类对象的基类部分。
  • 派生类的拷贝和移动构造函数在拷贝和移动自有成员的同时,也要拷贝和移动基类部分的成员。
  • 派生类赋值运算符,在为自身成员赋值的同时,也要对基类部分的成员进行赋值。
    默认情况下,基类的默认构造函数初始化对象的基类部分,要想使用拷贝或移动构造函数,我们必须在构思函数初始值列表中,显式地调用该构造函数
class Base{/**/};
class D: public Base{
public:
	//默认情况下,基类的默认构造函数初始化对象的基类部分,要想使用拷贝或移动
	//构造函数,我们必须在构思函数初始值列表中,显式地调用该构造函数
	D(const &D d):Base(d) // 拷贝基类成员
	{/*D的成员值拷贝*/} 
	D(D&& d):Base(std::move(d)) // 移动基类成员
	{/*D的成员的初始值*/}
	&D operator=(const D &rhs);
};
D &D::operator=(const D &rhs){
Base::operator=(rhs);  // 为基类部分赋值
//按照过去的方式为派生类的成员赋值,酌情处理自赋值情况及释放已有资源的情况
return *this;
}

析构函数体执行完成后,对象的成员会被隐式销毁,类似的,对象的基类部分也是隐式销毁的。因此,和构造函数及赋值运算符不同,派生类的析构函数只负责销毁由派生类自己分配的资源。

class D:public Base{
public:
	~D(){/*定义清除派生类成员的操作*/}
};

在构造函数和析构函数中调用虚函数

派生类对象在构造时,基类部分将首先被构建,当执行基类的构造函数时,该对象的派生类部分是未被初始化的状态;析构时的次序正好相反,当执行基类的析构函数时,派生类的成员部分已经被销毁了。由此可知,当我们在执行基类成员时可能会出现未完成的状态。
为了正确处理构造和析构中的这种未完成状态,我们在构建一个对象时,需要把对象的类和构造函数的类看做是同一个,对虚函数的调用绑定正好符合这种把对象的类和构造函数的类看成同一个的要求。

继承的构造函数

在C++11新标准中,派生类能够重用其直接基类定义的构造函数,尽管如我们所知,这些构造函数并非以常规的方式继承而来,但是为了方便,我们不妨将其称为“继承”的。一个类只初始化它的直接基类,出于同样原因,一个类也只继承其直接基类的构造函数。**类不能继承默认、拷贝和移动构造函数。**如果派生类没有直接定义这些构造函数,编译器将为派生类合成它们。
派生类继承基类构造函数的方式是提供一个注明了直接基类名的using声明语句。

class Bulk_quote : public Disc_quote{
public:
	using Disc_quote::Disc_quote;  // 继承Disc_quote的构造函数
	double net_price(std::size_t) const;
};

容器与继承

当我们使用容器存放继承体系中的对象时,通常必须采取间接存储的方式,因为不允许在容器中保存不同类型的元素,所以我们不能把具有继承关系的多种类型的对象直接存放在容器中。
解决方法是:在容器中放置(智能)指针而非对象
当我们希望在容器中存放具有继承关系的对象,我们实际上存放的通常是基类的指针(更好的选择是智能指针)指针所指对象的动态类型可能是基类也可能是派生类。

vector<shared_ptr<Quote>> basket;
basket.push_back(make_shared<Quote>("0-20-1022", 50));
basket.push_back(make_shared<Bulk_quote>("2212-4151-12", 50, 10,.25));

Basket类案例

我们定义一个表示购物篮的类,该类可以存放Quote的基类和派生类。我们的类使用一个multiset存放交易信息,这样我们就保存了同一本书的多条交易记录。multiset的元素类型是shared_ptr,由于shared_ptr没有定义小于运算符,所以我们要自己定义一个名为compare的私有静态成员,负责比较。

class Basket{
public:
	void add_item(const std::shared_ptr<Quote> &sale){items.insert(sale);};
	//打印每本书的总价和购物篮中所有书的总价
	double total_receipt(std::ostream) const;
private:
	static bool compare(const std::shared_ptr<Quote> &lhs, const std::shared_ptr<Quote> &rhs){return lhs->isbn() < rhs->isbn();}
	//multiset保存多个报价,按照compare成员排序
	std::multiset<std::shared_ptr<Quote>, decltype(compare)*>
	items{compare};	
};

Basket类只定义了两个操作。第一个成员是我们在类的内部定义的add_item成员,该成员接受一个指向动态分配的Quote的shared_ptr,然后将这个shared_ptr放置在multiset中。第二个成员的名字是total_receipt,它负责将购物篮的内容逐项打印为清单,然后返回购物篮中所有物品的价格。

double Basket::total_recipt(ostream &os) const
{
	double sum = 0.0;
	// upper_bound返回一个迭代器,指向这批元素的尾后位置,即下一种书籍的位置
	for(auto iter = items.cbegin();iter != items.cend();iter = items.upper_bound(*iter))
	{
		sum += print_total(os, **iter, items.count(*iter));
	}
	os << "Total Sale: " << sum << endl;
	return sum;
}

iter是multiset的迭代器,解引用iter,得到一个shared_ptr,再次解引用得到Quote对象或者其派生类对象,因此用到了**iter。print_total调用了虚函数net_price,因此最终的计算结果依赖于解引用出来的动态类型。

add接受一个shared_ptr参数,需要输入Quote的构造函数,仍然需要指明Quote或Bulk_quote的类型,下一步,我们希望重新定义add_item,使得它可以直接接受一个Quote对象。

void add_item(const Quote& sale);  // 拷贝
void add_item(Quote&& sale);  // 移动

给Quote添加一个虚拷贝函数,利用虚函数的特性动态绑定,我们分别定义拷贝版和移动版的拷贝函数。

class Quote{
public:
	virtual Quote* clone()const & {return new Quote(*this);}
	virtual Quote* clone() && {return new Quote (std::move(*this));}
};
class Bulk_quote:public Quote{
	Bulk_quote* clone() const & {return new Bulk_quote(*this);}
	Bulk_quote* clone() && {return new Bulk_quote(std::move(*this))}
};
class Basket{
public:
	void add_item(const Quote& sale)
	{items.insert(std::shared_ptr<Quote>(sale.clone()));}
	void add_item(Quote&& sale)
	{items.insert(std::shared_ptr<Quote>(std::move(sale).clone()));}
};

第16章:模板与泛型编程

定义模板

定义函数模板

  1. 函数模板
    有时我们定义的函数希望可以接收不同类型的形参,但函数体内容是一致,这个时候就可以使用函数模板,声明时不指定特定类型,而在实例化时根据形参类型实例化出相应的函数。
template <typename T>
int compare(const T &v1, const T &v2)
{
	if(v1 < v2) return -1;
	if (v2 < v1) return 1;
	return 0;
}

vec1 = {1,2,3}, vec2{4,5,6};
cout << compare(vec1, vec2) << endl;  //实例化时:T将被替换为vector<int>类型
cout << compare(1, 0) << endl; // T将被替换为int类型
  1. 非类型模板参数

    一个非类型参数表示一个值而非一个类型,我们通过一个特定的类型名而非关键字class或typename来指定非类型参数。

    当一个模板被实例化时,非类型参数被一个用户提供的或编译器推断的值所代替。这些值必须是常量表达式,从而允许编译器在编译时实例化。

    例如,我们可以编写一个compare版本处理字符串字面常量,这种字面值常量的const char 的数组。由于我们希望比较不同长度的字符串字面值常量,因此为模板定义了两个非类型参数M,N分别表示数组的长度。
    compare("hi", "mom");调用时,编译器使用字面值常量的大小代替N和M

template<unsigned N, unsigned M>
int compare(const char (&p1)[N], const char (&p2) [M])
{
	return strcmp(p1, p2);
}
compare("hi", "mom");

编译器通常为字符串数组尾部插入一个空字符作为终结符,故实例化的版本相当于:

int compare(const char(&p1)[3], const char(&p2[4]))
{
	return strcmp(p1, p2);
}
  1. inline和constexpr的函数模板
    inline或者constexpr说明符放置在模板参数之后,返回类型之前。
template<typename T>inline T min(const T&, const T&);
  1. 我们最初的compare函数虽然简单,但是它说明了编写泛型代码最重要的两个原则:
    • 模板中的函数参数是const的引用。使用引用可以保证函数可以用于接收不能拷贝的形参,泛用更好。
    • 函数体中的条件判断仅使用<运算。仅使用“<”降低了类型的运算要求,只需要支持“<”即可。
  2. 模板编译。
    • 编译器遇到模板定义时并不生成代码,仅在实例化时生成代码。
    • 模板的头文件通常既包括声明也包括定义,这与非模板代码不同。
    • 大多数编译错误发生在实例化期间,编译分为三个阶段:编译模板本身、模板使用、模板实例化。
    • 普通函数与函数模板的区别:

  • 普通函数调用时自动类型转换(隐式类型转换)
  • 函数模板调用时如果利用自动推导,不会发生隐式类型转换
  • 如果利用显示指定类型的方式,可以发生隐式类型转换
    所谓隐式类型转换,即函数自动将实参的类型转为形参指定的类型
int addNum(int a, int b)
{
	return a+b;
}
void test()
{
	int a = 10;
	int b = 20;
	char c = 'c'; // a-97 c-99
	cout << addNum(a,c) << endl;
}

cout << addNum(a,c) << endl;
会输出109,因为函数会自动将字符c转为其对应的ASCLL码99

普通函数与函数模板的同名时调用规则

  • 如果普通函数和函数模板都可以实现,优先调用普通函数
  • 可以通过空模板参数列表<>来强制调用函数模板
  • 函数模板也可以发生重载
  • 如果函数模板可以产生更好的匹配,优先调用函数模板

函数模板并非万能,有些特点数据类型,需要具体化方式做特殊实现

#include<fstream>
#include<iostream>
#include<string>
using namespace std;

class Person
{
public:
	Person(string name, int age)
	{
		this->name = name;
		this->age = age;
	}
	string name;
	int age;
};

template<class T>
bool myCompare(T& a, T& b)
{
	if (a == b)
	{
		return true;
	}
	else
	{
		return false;
	}
}

// 利用具体化的Person版本实现代码,具体化优先调用
template<> bool myCompare(Person& a, Person& b)
{
	if (a.name == b.name && a.age == b.age)
	{
		return true;
	}
	else 
	{
		return false;
	}
}

void test02() 
{
	Person p1("Tom", 10);
	Person p2("Tom", 10);
	bool ret = myCompare(p1, p2);
	if (ret)
	{
		cout << "p1==p2" << endl;
	}
	else
	{
		cout << "p1!=p2" << endl;
	}
}


int main()
{

	test02();
	system("pause");
	return 0;
}

定义类模板

#include<fstream>
#include<iostream>
#include<string>
using namespace std;

// 类模板
template<class NameType, class AgeType>
class Person
{
public:
	Person(NameType name, AgeType age);

	void showPerson();

	NameType m_Name;
	AgeType m_Age;
};
// 构造函数类外实现
template <class T1, class T2>
Person<T1, T2>::Person(T1 name, T2 age)
{
	this->m_Age = age;
	this->m_Name = name;
}
// 成员函数的类外实现
template <class T1, class T2>
void Person<T1, T2>::showPerson()
{
	cout << "name:" << this->m_Name << "age:" << this->m_Age << endl;
}

void test01()
{
	Person<string, int>P("Tom", 10);
	P.showPerson();
}

int main()
{
	test01();
	system("pause");
	return 0;
}

构造函数类外实现
template <class T1, class T2> Person<T1, T2>::Person(T1 name, T2 age)
成员函数的类外实现
template <class T1, class T2> void Person<T1, T2>::showPerson()

  1. 与其他任何类相同,我们即可在类模板内部定义成员函数,也可定义在类模板外定义,且定义在类模板内部的成员函数隐式声明为内联函数。
  2. 在一个类模板的作用域内,我们可以直接使用模板名而不必指定模板实参。
  3. 类模板可以声明static成员,与普通类一样,类模板的static成员必须有且只有一个定义。但是类模板的每个实例都有一个独有的static,为了与定义模板的成员函数类似,可将static数据成员也定义为模板。
成员模板

普通类或者模板类都可定义成员模板,成员模板不能是虚函数。

普通类的成员模板示例:
DebugDelete是一个删除器,可以删除任意类型的数据指针,在删除前调用cerr打印删除信息。

class DebugDelete{
public:
	DebugDelete(std::ostream &s = std::cerr): os(s){}
	template <typename T> void operator()(T *p) const
	{os << "deleting unique_ptr" << std::endl; delete p; }
private:
	std::ostream &os;	
};

double* p = new double;
DebugDelete d;
d(p);

由于调用DebugDelete对象会delete给定指针,我们也可以将DebugDelete用作unique_ptr的删除器,为了重载unique_ptr的删除器,我们在尖括号内需要给出删除器类型,并提供一个这种类型的对象给unique_ptr的构造函数。

unique_ptr<int, DebugDelete> p(new int,DebugDelete());
unique_ptr<string, DebugDelete> p(new string,DebugDelete());

类模板的成员模板

类模板也可以有成员模板,此时,类和成员将各自有自己的、独立的模板参数。

例如下面例子将Blob的构造函数设置为模板

template <typename T> class Blob{
	template <typename It> Blob(It b, It e);
	//……
};

template <typename T>
template <typename It>
	Blob<T>::Blob(It b, It e):data(std::make_shared<std::vectorM<T>>(b, e)){ }

为了实例化一个类模板的成员模板,我们必须同时提供类和函数模板的实参。

控制实例化

模板在使用时才进行实例化,这一特性意味着相同的实例可能出现在多个对象文件中。当两个或多个独立编译的源文件使用了相同的模板,并提供了相同的模板参数时,每个文件中都会有该模板的一个实例,带来不必要的开销。

C++新标准下,可以通过显式实例化来避免这种开销:

extern template declaration;  //实例化声明
template declaration; //实例化定义

declaration是一个类或函数声明,其中所有模板参数已经被替换为模板实参:

extern template class Blob<string>;  //  声明
template int compare(const int&, const int&);  // 定义

编译器通过extern关键字得知程序其他位置有该实例化的一个非extern声明(定义),因此可以有多个extern声明,但必须只有一个实例化定义。

类模板分文件编写

问题

  • 类模板中成员函数在调用阶段才会创建,导致分文件编写时链接不到

解决

  • 解放方式1:直接包含.cpp源文件
  • 解决方式2:将声明和实现写到同一个文件中,并更改后缀名为.hpp,hpp是约定名称,并不强制
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值