layout: post
title: C++prime读书笔记(三)拷贝控制、运算重载与类型转换、对象继承、模板与泛型
description: C++prime读书笔记(三)拷贝控制、运算重载与类型转换、对象继承、模板与泛型
tag: 读书笔记
当定义一个类时,我们显式或隐式地指定在此类型的对象拷贝、移动、赋值和销毁时做什么,这些操作统称为拷贝控制操作,一个类通过定义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数据成员。与普通指针不同,智能指针是类类型,所以具有析构函数。
以下情况会调用析构函数:
当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数,类似拷贝构造函数和拷贝赋值运算符,在(空)析构函数体执行完毕后,成员会被自动销毁,析构函数体自身并不直接销毁成员,成员是在析构函数体之后隐含的析构阶段中被销毁的,在整个对象销毁的过程中,析构函数体是作为成员销毁步骤之外的另一部分而进行的
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,就像下面例子中对于拷贝赋值运算符=
所做的那样。
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。
对于某些类来讲拷贝和赋值没有合理的意义,因此在定义这些类时必须采用某种机制阻止拷贝或赋值。例如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可以通过拷贝并交换来实现它们的赋值运算符。
HasPtr& HasPtr::operator=(HasPtr rhs)
{
swap(*this, rhs);
return *this;
}
注意:这个版本的赋值运算符参数并不是const &,我们将右侧对象以传值方式传递给赋值运算符,rhs是右侧对象的一个副本,调用swap来交换rhs和*this中的数据成员,当赋值运算符结束时,这个副本rhs被销毁,HasPtr的析构函数将执行,此析构函数delete了rhs现在指向的内存,即释放掉左侧运算对象中原来的内存。这种拷贝并交换的技术自动处理了自赋值情况且天然就是异常安全的,因为它自动在赋值前拷贝了=右侧的副本,并通过swap赋值右侧对象的同时,保证了函数结束时,左侧对象被销毁。
作为类需要拷贝控制操作的例子,下面设计两个类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);
}
实现标准库vector的一个简化版本,不使用模板,只用于string。
StrVec使用一个allocator来获得原始内存,由于allocator分配的内存是未构造的,我们需要添加新元素时使用allocator的construct成员在原始内存中创建对象,类似地,当需要删除一个元素时,我们将使用destroy成员来销毁元素。
每个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的新标准库函数(在头文件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; // 返回副本
}
如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符
输出运算符 <<
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的行为可以是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
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对象的调用运算符
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; // 正确:显式地请求类型转换
当类型转换符是显式的时候,我们也能执行类型转换,不过必须通过显式的强制类型转换才可以。该规定存在一个例外,当表达式出现在以下位置时,显式的类型转换将被隐式地执行:
面向对象设计基于三个基本概念:封装、继承和多态。
面向对象程序设计的核心思想是数据抽象、继承和动态绑定。
继承
:通常在层次关系的根部有一个基类,其他类直接或间接由基类继承而来。称为派生类。C++语言中基类将类型相关的函数与派生类不做改变直接继承的函数区分对待,对于某些函数,基类希望它的派生类各自定义适合自身版本的函数,此时基类就将这些函数声明为虚函数,在声明前加关键字virtual。派生类必须通过类派生列表明确指出它是从哪个(哪些)基类继承而来,类派生列表的形式是:首先一个冒号,后边紧跟着以逗号分隔的基类列表,每个基类前面可以有访问说明符(public、private、protected)。C++11标准允许派生类显式地注明它使用哪个成员函数改写基类的虚函数,具体措施是在该形参列表后增加一个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;
};
动态绑定
:即运行时绑定,在函数运行时,选择使用函数的版本。动态绑定允许程序在运行时,根据形参是基类或派生类,来选择使用基类的函数或派生类的函数。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()
虚函数对应一个vtable,vtable是存储在对象的内存空间的。问题出来了,如果构造函数是虚的,就需要通过 vtable来调用,可是对象还没有实例化,也就是内存空间还没有,无法找到vtable,所以构造函数不能是虚函数。
②从使用角度
虚函数的作用在于通过父类的指针或者引用来调用它的时候能够变成调用子类的那个成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类的指针或者引用去调用,因此也就规定构造函数不能是虚函数。
任何构造函数之外的非静态函数都可以是虚函数,关键字virtual只能出现在类内部的声明语句之前,而不能用于类外部的函数定义,如果一个基类把一个函数声明为虚函数,则该函数在派生类中隐式地也是虚函数。
因为派生类中含有与其基类对应的组成部分,所以我们能把派生类的对象当成基类对象来使用,而且我们也能将基类的指针或引用绑定到派生类对象中的基类部分上。
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++使用基类的引用或指针调用一个虚成员函数时会执行动态绑定,对虚函数的调用可能在运行时才被解析。
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成员,而不对普通基类对象有特殊访问权限。
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成员
}
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个步骤:
通过上边的函数执行解析过程,就可以理解,为什么基类与派生类中的虚函数必须有相同的形参列表。假如基类与派生类的虚函数接受的实参不同,则我们就无法通过基类的引用或者指针调用派生类的虚函数了。
位于继承体系中的类也需要控制当其对象执行创建、拷贝、移动、赋值和销毁时的行为,需要定义拷贝控制操作。
合成拷贝控制与继承
合成拷贝控制成员对类本身的成员依次进行初始化、赋值或销毁的操作,此外这些合成的成员还负责使用直接基类中对应的操作对一个对象的直接基类部分进行初始化、赋值或销毁的操作,例如:
派生类中删除的拷贝控制与基类的关系
移动操作与继承
如前所述,大多数基类会定义虚析构函数,因此默认情况下,基类通常不含有合成的移动操作,故派生类中也没有合成的移动操作。
因为基类中缺少移动操作会阻止派生类拥有自己的合成移动操作,当我们确实需要执行移动操作时,应该首先在基类中进行定义。
一旦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));
我们定义一个表示购物篮的类,该类可以存放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()));}
};
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类型
cout << compare(1, 0) << endl; // T将被替换为int类型
非类型模板参数
一个非类型参数表示一个值而非一个类型,我们通过一个特定的类型名而非关键字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);
}
template<typename T>inline T min(const T&, const T&);
隐式类型转换
,即函数自动将实参的类型转为形参指定的类型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
#include
#include
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
#include
#include
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
成员函数的类外实现
template
定义在类模板内部的成员函数隐式声明为内联函数。
普通类或者模板类都可定义成员模板,成员模板不能是虚函数。
普通类的成员模板示例:
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声明,但必须只有一个实例化定义。
问题
解决
.hpp
,hpp是约定名称
,并不强制