十三. 重载运算与类型转换
1. 基本概念
当运算符作用于类类型的运算对象时,可以通过运算符重载重新定义该运算符的含义。
重载的运算符是具有特殊名字的函数:他们的名字由关键字operator和其后要定义的运算符号共同组成。和其他函数一样,重载的运算符也包含返回类型、参数列表以及函数体。
重载运算符的参数数量与该运算符作用的运算对象数量一样多。一元运算符有一个参数,二元运算符有两个。对于二元运算符来说,左侧运算对象传递给第一个参数,而右侧运算对象传递给第二个参数。除了重载的函数调用运算符operator()外,其他重载的运算符不能含有默认实参。
若一个运算符函数是成员函数,则它的第一个(左侧)运算对象绑定到隐式的this指针上,因此,成员运算符函数的(显式)参数数量比运算符的运算对象总数少一个。
当运算符作用于内置类型的运算对象时,无法改变该运算符的含义。
只能重载已有的运算符,无权发明新的符号。
有四个符号(+, -, *, &)既是一元运算符,也是二元运算符,从参数的数量可以推断定义的是哪种运算符。
对于一个重载的运算符来说,其优先级和结合律与对应的内置运算符保持一致。
直接调用一个重载的运算符函数:通常,运算符作用于类型正确的实参,从而以这种间接方式"调用"重载的运算符函数。然而,我们也能像调用普通函数一样直接调用运算符函数,先指定函数名字,然后传入数量正确、类型适当的实参。
也可以像调用其他成员函数一样,显式的调用成员运算符函数。首先指定运行函数的对象(或指针)的名字,然后使用点运算符(或箭头运算符)访问希望调用的函数
//一个非成员运算符函数的等价调用,传入data1作为第一个实参, data2作为第二个实参
data1+data2;//普通的表达式
operator+(data1, data2);//等价的函数调用
//this绑定到data1的地址,data2传入实参
data1+=data2;//基于调用的表达式
data1.operator+=(data2);//对成员运算符函数的等价调用
某些运算符不应该被重载(逗号,取地址,逻辑与、逻辑或):某些运算符指定了运算对象求值的顺序。因为使用重载的运算符本质上是一次函数调用,所以这些关于运算对象求值顺序的规则无法应用到重载的运算符上。特别是:逻辑与、逻辑或和逗号运算符的运算对象求值顺序规则无法保存下来。此外,&&和||运算符的重载版本也无法保留内置运算符的短路求值属性。所以,这种无法保留运算符的求值顺序或短路属性的重载版本不建议重载。
还有一个原因使得一般不重载逗号运算符和取地址运算符:C++语言已经定义了这两种运算符用于类类型对象时的特殊含义。因为这两种运算符已经有了内置含义,所以一般不重载。
使用与内置类型一致的含义:某些操作在逻辑上与运算符相关,则他们适合定义成重载的运算符:
[1] 若类指向IO操作,则定义移位运算符使其与内置类型的IO保持一致。
[2] 若类的某个操作是检查相等性,则定义operator==, 且定义operator!=
[3] 若类包含一个内在的单序比较操作,则定义operator<同时定义其他关系运算符。
[4] 重载运算符的返回类型通常应与内置版本的返回类型兼容:逻辑运算符和关系运算符应该 返回bool, 算术运算符应返回一个类类型的值,赋值运算符和复合赋值运算符则返回左侧对象的一个引用。
选择作为成员或者非成员:定义重载运算符时,首先要决定将其声明为类的成员函数还是声明为一个普通的非成员函数。某些情况运算符必须作为成员;另一些情况,运算符作为普通函数比较好。
[1] 赋值(=)、下标([])、调用( () )和成员访问箭头(->)运算符必须是成员
[2] 复合赋值运算符一般来说是成员,但非必须。
[3] 改变对象状态的运算符或者与给定类型密切相关的运算符(递增、递减、解引用), 通常应该是成员。
[4] 具有对称性的运算符可能转换任意一端运算对象,例如:算术、相等性、关系和位运算符等,通常应是普通的非成员函数。
2. 输入和输出运算符
IO标准库分别使用>>和<<执行输入和输出操作。对于这两个运算符来说,IO库定义了用其读写内置类型的版本,而类则需要自定义适合其对象的新版本以支持IO操作。
①重载输出运算符<<
通常情况下,输出运算符的第一个形参是一个非常量ostream对象的引用。非常量是因为向流写入内容会改变其状态;该形参是引用是因为无法直接复制一个ostream对象。
第二个形参一般是一个常量引用,该常量是我们想要打印的类类型。第二个形参是引用时我们希望避免复制形参;是常量,是因为打印该对象不会改变对象的内容。
为了与其他输出运算符一致,operator<<一般反回他的ostream形参。
operator<<输出运算符不能作为成员函数来定义,因为成员函数无法满足运算符左操作数为输出流的需求(this)。通常我们将其定义为非成员函数来实现所需的功能。
根据重载函数的匹配规则,可以使重载的<<绑定到operator类上
struct Sales_data{
string bookNo="123";
unsigned units_sold={0};
double revenur=0.0;
};
ostream &operator<<(ostream &os, const Sales_data &item)
{
os<<item.bookNo;
return os;
}
输出运算符尽量减少格式化操作:输出运算符应该主要赋值打印对象的内容而非格式,使得打印更加灵活。
输入输出运算符必须是非成员函数:与iostream标准库兼容的输入输出运算符必须是普通的非成员函数,而不能是类的成员函数。否则,他们的左侧运算对象将是我们类的一个对象
Sales_data data;
data<<cout;//若operator<<是Sales_data的成员。
假设输入输出运算符是某个类的成员,则他们也必须是istream或ostream的成员。但这两个类属于标注库,我们无法在标准库添加任何成员。
因此,若为类自定义IO运算符,必须将其定义为非成员函数。IO运算符通常需要读写类的非公有数据成员,所以IO运算符一般被声明为友元。
②重载输入运算符>>
通常情况,输入运算符的第一个形参是运算符将要读取的流的引用,第二个形参是将要读入到的(非常量,改变了对象)对象的引用。该运算符通常返回某个给定流的引用。
输入运算符必须处理可能失败的情况,而输出运算符不需要。
struct Sales_data{
string bookNo="123";
unsigned units_sold={0};
double revenue=0.0;
};
istream &operator>>(istream &is, Sales_data &item)
{
double price;
is>>item.bookNo>>item.units_sold>>price;
if(is)//检查输入是否成功
item.revenue=item.units_sold*price;
else
item=Sales_data();//输入失败,构造默认对象
return is;
}
输入时的错误:
[1] 当流含有错误类型的数据时读取操作可能失败。
[2] 当读取操作达到文件末尾或者遇到输入流的其他错误时也会失败。
通过将对象置为合法状态,免于受输入错误影响。
标示错误:一些输入运算符需要做更多数据验证工作(业务等)。
3. 算术和关系运算符
通常情况下把算术和关系运算符定义成非成员函数以允许对左侧或右侧的运算对象进行转换。由于无需改变运算对象的状态,所以形参都是常量的引用。
若类定义了算术运算符,一般也会定义一个对应的复合赋值运算符,使用符合赋值运算符实现算术运算符。
struct Sales_data{
string bookNo="123";
unsigned units_sold={0};
double revenue=0.0;
};
Sales_data operator+(const Sales_data &lhs, const Sales_data &rhs)
{
Sales_data sum=lhs;//拷贝构造函数
sum+=rhs;//使用符合赋值运算符累加
return sum;
}
①相等运算符
类通过定义相等运算符来检验两个对象是否相等。
struct Sales_data{
string bookNo="123";
unsigned units_sold={0};
double revenue=0.0;
};
bool operator==(const Sales_data &lhs, const Sales_data &rhs)
{
return lhs.bookNo==rhs.bookNo&&
lhs.units_sold==rhs.units_sold&&
lhs.revenue==rhs.revenue;
}
bool operator!=(const Sales_data &lhs, const Sales_data &rhs)
{
return !(lhs==rhs);
}
设计准则:
[1] 若一个类有判断相等操作,应该定义为operator==而非普通命名函数,容易使用标准库算法,也不用记函数名。
[2] 若类定义了operator==,则该运算符应该能判断一组给定的对象中是否含有重复数据。
[3] 通常相等运算符具有传递性,即:a==b, b==c则a==c
[4] 若定义==,则也应定义!=
[5] 相等运算符和不相等运算符应该把工作委托给另一个。
②关系运算符
定义了相等运算符的类也常常包含关系运算符。特别是,因为关联容器和一些算法要用到小于运算符,所以定义operator<比较有用。
若存在唯一一种逻辑可靠的<定义,则应该考虑为这个类定义<运算符。若类同时包含==, 则当且仅当<的定义和==产生的结果一致时才定义<运算符。
4. 赋值运算符
之前定义过拷贝赋值和移动赋值运算符,他们可以把类的一个对象赋值给该类的另一个对象。此外,类还可以定义其他赋值运算符以使用别的类型作为右侧运算对象。
例如:在拷贝和移动赋值运算符外,标准库vector还定义了第三种赋值运算符,该运算符接受花括号内的元素列表作为参数。
无论形参类型是什么,赋值运算符都必须定义为成员函数。
//类vector类内存分配策略的简化实现
class StrVec
{
public:
//为了与内置类型的赋值运算符保持一致,这个新的赋值运算符将返回其左侧运算对象的引用
StrVec &operator=(initializer_list<string>);//将花括号运算符加入该类
private:
pair<string*, string*> alloc_n_copy(const string*, const string*);
void free();//销毁元素并释放内存
string *elements;//指向数组首元素的指针
string *first_free;//指向数组第一个空闲元素的指针
string *cap;//指向数组内存尾后的指针
};
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;
}
复合赋值运算符:
复合赋值运算符不一定非是类成员,但是倾向把赋值运算符定位为成员。为了与内置类型的复合赋值保持一致,类中的复合赋值运算符也返回左侧运算对象的引用。
struct Sales_data{
string bookNo="123";
unsigned units_sold={0};
double revenue=0.0;
Sales_data& operator+=(const Sales_data &rhs);
};
Sales_data& Sales_data::operator+=(const Sales_data &rhs)
{
units_sold+=rhs.units_sold;
revenue+=rhs.revenue;
return *this;
}
Sales_data operator+(const Sales_data &lhs, const Sales_data &rhs)
{
Sales_data sum=lhs;//拷贝构造函数
sum+=rhs;//使用符合赋值运算符累加
return sum;
}
5. 下标运算符
下标运算符必须是成员函数。
表示容器的类通常可以通过元素在容器中的位置访问元素,这些类一般会定义下标运算符operator[].
为了与下标的原始定义兼容,下标运算符通常以所访问的元素的引用作为返回值,这样下标可以出现在赋值运算符的任意一端。进一步,最好同时定义下标运算符的常量和非常量版本,当作用于一个常量对象时,下标运算符返回常量引用,以确保不会给返回的对象赋值。
//类vector类内存分配策略的简化实现
class StrVec
{
public:
string& operator[](size_t n)
{
return elements[n];
}
const string& operator[](size_t n) const
{
return elements[n];
}
private:
string *elements;//指向数组首元素的指针
};
6. 递增和递减运算符
因为递增递减正好改变操作对象的状态,所以将其设定为成员函数。
应实现前置版本和后置版本。
定义前置递增/递减运算符:
为与内置版本一致,前置运算符应返回递增或递减后对象的引用。
class StrBlobPtr
{
public:
//递增/递减运算符
StrBlobPtr& operator++();
StrBlobPtr& operator--();
StrBlobPtr(): curr(0){}
StrBlobPtr(StrBlob &a, size_t sz=0):wptr(a.data), curr(sz) {}
string& deref() const;
StrBlobPtr& incr();//前缀递增
private:
//若检查成功,check返回一个指向vector的shared_ptr
shared_ptr<vector<string>> check(size_t i, const string &msg) const
{
auto ret=wptr.lock();//vector还存在吗?
if(!ret) throw runtime_error("未绑定的StrBlobPtr");
if(i>=ret->size()) throw out_of_range(msg);
return ret;//否则返回指向vector的shared_ptr
}
//保存一个weak_ptr, 意味着底层vector可能被销毁
weak_ptr<vector<string>> wptr;
size_t curr;//在数组当前位置
};
StrBlobPtr& StrBlobPtr::operator++()
{
check(curr, "sa");//若已经指向了尾后,无法递增
++curr;
return *this;
}
StrBlobPtr& StrBlobPtr::operator--()
{
--curr;
check(curr, "?");
return *this;
}
区分前置和后置运算符:要想同时定义前置和后置运算符,必须解决一个问题,即普通的重载形式无法区分者两种情况。前置后置版本使用的是同一个符号,且运算对象的数量和类型也相同。
为了解决上述问题,后置版本接受一个额外的(不被使用)int类型的形参。当我们使用后置运算符时,编译器为这个形参提供一个值为0的实参。这个形参的唯一作用就是区分前置后置版本函数。
为了与内置版本保持一致,后置运算符应返回对象的原值,返回的形式是一个值而非引用。
class StrBlobPtr
{
public:
//递增/递减运算符
StrBlobPtr& operator++();//前置
StrBlobPtr& operator--();
StrBlobPtr operator++(int);//后置
StrBlobPtr operator--(int);
StrBlobPtr(): curr(0){}
StrBlobPtr(StrBlob &a, size_t sz=0):wptr(a.data), curr(sz) {}
string& deref() const;
StrBlobPtr& incr();//前缀递增
private:
//若检查成功,check返回一个指向vector的shared_ptr
shared_ptr<vector<string>> check(size_t i, const string &msg) const
{
auto ret=wptr.lock();//vector还存在吗?
if(!ret) throw runtime_error("未绑定的StrBlobPtr");
if(i>=ret->size()) throw out_of_range(msg);
return ret;//否则返回指向vector的shared_ptr
}
//保存一个weak_ptr, 意味着底层vector可能被销毁
weak_ptr<vector<string>> wptr;
size_t curr;//在数组当前位置
};
StrBlobPtr& StrBlobPtr::operator++()
{
check(curr, "sa");//若已经指向了尾后,无法递增
++curr;
return *this;
}
StrBlobPtr& StrBlobPtr::operator--()
{
--curr;
check(curr, "?");
return *this;
}
StrBlobPtr StrBlobPtr::operator++(int)
{
StrBlobPtr ret=*this;
++*this;//调用前置版本完成实际工作
return ret;
}
StrBlobPtr StrBlobPtr::operator--(int)
{
StrBlobPtr ret=*this;
--*this;
return ret;
}
显示的调用后置运算符:
StrBlobPtr p(a1);//p指向a1中的vector
p.operator++(0);//后置版本
p.operator++();//前置版本
7. 成员访问运算符
在迭代器及智能指针中常常用到解引用和箭头运算符。通常他们都是类的成员。
class StrBlob
{
public:
typedef vector<string>::size_type size_type;
StrBlob();//默认构造函数
StrBlob(initializer_list<string> il);//可变参数,列表初始化
size_type size() {return data->size();}//返回vector元素数量
bool empty() const {return data->empty();}
//添加和删除元素
void push_back(const string &t) {data->push_back(t);}
void pop_back()
{
check(0, "空");
data->pop_back();
}
//元素访问
string& front()
{
//若vector为空, check会抛出异常
check(0, "空");
return data->front();
}
string& back()
{
check(0, "空");
return data->back();
}
private:
//对StrBlob的拷贝赋值和销毁会引起shared_ptr的计数器变化,直到无引用者,该对象销毁,释放内存
shared_ptr<vector<string>> data;//若data[i]不合法会抛出异常
/**
* 检查给定索引i是否在合法范围
* @param i
* @param msg 描述错误信息,被传给异常处理程序
*/
void check(size_type i, const string &msg) const
{
if(i>=data->size())
throw out_of_range(msg);
}
};
class StrBlobPtr
{
public:
string& operator*() const//定义成const是因为不会改变对象状态
{
auto p= check(curr, "de");
return (*p)[curr];//(*p)是对象所指的vector, (*p)[curr],返回curr下标的string元素
}//最后返回,string的引用
string* operator->() const
{//this是一个指针,(*this).operator*(),*是内置*
// this->operator*() 调用了上面的解引用运算符 operator*,获取一个 string 的引用
return & this->operator*();//实际工作委托给解引用运算符, 返回string*
}
StrBlobPtr(): curr(0){}
StrBlobPtr(StrBlob &a, size_t sz=0):wptr(a.data), curr(sz) {}
string& deref() const;
StrBlobPtr& incr();//前缀递增
private:
//若检查成功,check返回一个指向vector的shared_ptr
shared_ptr<vector<string>> check(size_t i, const string &msg) const
{
auto ret=wptr.lock();//vector还存在吗?
if(!ret) throw runtime_error("未绑定的StrBlobPtr");
if(i>=ret->size()) throw out_of_range(msg);
return ret;//否则返回指向vector的shared_ptr
}
//保存一个weak_ptr, 意味着底层vector可能被销毁
weak_ptr<vector<string>> wptr;
size_t curr;//在数组当前位置
};
int main(int argc, char *argv[])
{
StrBlob a1={"ja", "sa", "da"};
StrBlobPtr p(a1);//p指向a1中的vector
*p="okey";//给a1首元素赋值
cout<<p->size();//4, 这是a1首元素的大小
cout<<(*p).size();//等价于上面
}
对箭头运算符返回值的限定:operator*可完成任何我们指定的操作。但箭头运算符不能丢掉成员访问的最基本含义。
对于形如point->mem的表达式来说,point必须是指向类对象的指针或者是一个重载了operator->的类的对象。根据point类型的不同,point->mem分别的等价于
(*point).mem;//point是一个内置的指针类型
point.operator()->mem;//point是类的一个对象
除此之外,代码都将发生错误。point->mem执行过程如下:
[1] 若point是指针,则我们应用内置的箭头运算符,表达式等价于(*point).mem。首先解引用该指针,然后从所得对象获取指定成员。
[2] 若point是定义了operator->的类的一个对象,则我们使用point.operator->()的结果获取mem。若该结果是一个指针,则执行第一步;若该结果本身含有重载的operator->(), 则重复当前步骤.
重载的箭头运算符必须返回类的指针或者自定义了箭头运算符的某个类的对象。
区分自定义和内置*,->:关键在于,使用的对象是指针还是某个类的对象
class MyClass {
public:
string& operator*() const {
static string s = "Hello, World!";
return s;
}
};
int main() {
MyClass obj;
MyClass* point = &obj;
// 内置的指针解引用运算符
MyClass& objRef = *point;
// 自定义的operator*,返回string的引用
string& str = *objRef;
// 也可以这样写
string& str2 = (*point).operator*();
// 输出结果
cout << str << endl; // 输出 "Hello, World!"
cout << str2 << endl; // 输出 "Hello, World!"
return 0;
}
8. 函数调用运算符
若类重载了函数调用运算符,则可以像使用函数一样使用该类的对象。因为这样的类同时能存储状态,与普通函数相比更加灵活。
函数调用运算符必须是成员函数。一个类可以定义多个不同版本的调用运算符,相互之间应在参数数量或类型上有所区别。
若类定义了调用运算符,则该类的对象称作函数对象。
/**
* 这个类只定义了一种操作:函数调用运算符,它负责接收一个int类型
* 的实参,然后返回该实参的绝对值
*/
struct absInt
{
int operator()(int val) const//负责返回其参数的绝对值
{
return val<0?-val:val;
}
};
int main(int argc, char *argv[])
{
int i=-42;
absInt absObj;
int ui=absObj(i);//将i传给absObj.operator()
//即使absObj只是一个对象而非函数,也能‘调用’该对象。调用对象
//实际上是在运行重载的调用运算符。在此例中,该运算符接受一个int,返回其绝对值
}
含有状态的函数对象类:函数对象除了operator()外,也可以包含其他成员。函数对象类通常含有一些数据成员,这些成员被用于定制调用运算符中的操作。
class PrintString
{
public:
PrintString(ostream &o=cout, char c=' '):os(o), sep(c) {}
void operator()(const string &s) const//函数调用运算符,无左右对象所以不用绑定this
{
os<<s<<sep;
}
private:
ostream &os;//用于写入的目的流
char sep;//将不同输出隔开的字符
};
int main(int argc, char *argv[])
{
string s="1";
PrintString printer;//使用默认打印到cout
printer(s);//在cout中打印s,后面跟一个空格
PrintString errors(cerr, '\n');
errors(s);//在cerr中打印s, 后面跟一个换行符
//函数对象常作为泛型算法的实参。例如:
vector<string> vs={"a", "b"};
//当程序调用for_each时,会把vs中每个元素依次打印到cerr中,元素间以换行符分割
for_each(vs.begin(), vs.end(), PrintString(cerr, '\n'));
}
①lambda是函数对象
当编写了一个lambda后,编译器将该表达式翻译成一个未命名类的未命名对象。在lambda表达式产生的类中含有一个重载的函数调用运算符。例如下:
//下面的lambda和上面的函数对象等价
/*
* 默认情况下lambda不能改变它捕获的变量。因此默认情况
* 下,由lambda产生的类当中的函数调用运算符是一个const
* 成员函数。若lambda被声明为可变的,则调用运算符就不是const的了
*/
class ShorterString
{
public:
bool operator()(const string &s1, const string &s2) const
{
return s1.size()<s2.size();
}
};
int main(int argc, char *argv[])
{
vector<string> words;
stable_sort(words.begin(), words.end(),
[](const string &a, const string &b)
{
return a.size()<b.size();
});
stable_sort(words.begin(), words.end(), ShorterString());//等价于上面
}
表示lambda及相应捕获行为的类:
当一个lambda表达式通过引用捕获变量时,将由程序负责确保lambda执行时引用所引的对象确实存在。因此,编译器可以直接使用该引用而无需在lambda产生的类中将其存储为数据成员。
通过值捕获的变量被拷贝到lambda中。这种lambda产生的类必须为每个值捕获的变量建立对应的数据成员,同时创建构造函数,令其使用捕获的变量的值来初始化数据成员。
//对应下面的lambda
class SizeComp
{
public:
SizeComp(size_t n):sz(n) {}//该形参对应捕获的变量
//该调用运算符的返回类型形参和函数体都与lambda一致
bool operator()(const string &s) const
{
return s.size()>=sz;
}
private:
size_t sz;//该数据成员对应值捕获的变量
};
int main(int argc, char *argv[])
{
//找到第一个不小于给定值的string对象
int sz;
vector<string> words;
//获得第一个指向满足条件元素的迭代器,该元素满足size()>=sz
auto wc=find_if(words.begin(), words.end(),
[sz](const string &a)
{
return a.size()>=sz;
});
wc=find_if(words.begin(), words.end(), SizeComp(sz));
}
lambda表达式产生的类不含默认构造函数、赋值运算符以及默认析构函数;它是否含有默认的拷贝/移动构造函数则通常要视捕获的数据成员类型而定。
②标准库定义的函数对象
标准库定义了一组表示算术运算符、关系运算符和逻辑运算符的类,每个类分别定义了一个执行命名操作的调用运算符。
这些类都被定义成模板的形式,可以为其指定具体的应用类型,这里的类型即调用运算符的形参类型。
plus<int> intAdd;
int sum=intAdd(10, 20);//sum为30
在算法中使用标准库函数对象:
表示运算符的函数对象常用来替换算法中的默认运算符。
/**
* 默认情况下排序算法使用operator<将序列按照升序排列
* 若要执行降序排列,则传入greater.该类将产生一个调用
* 运算符并负责执行排序运算
*
* 当sort元素时不再使用默认的<,而是使用greater函数对象
* 即:当a>b时,等价于原来的a<b
*/
vector<string> svec;
sort(svec.begin(), svec.end(), greater<string>());
/**
* 标准库规定的函数对象对指针同样适用
* 比较两个指针将产生无定义行为,然而我们可能希望
* 比较指针的内存地址来sort指针的vector, 可使用
* 标准库函数对象来实现
*/
vector<string *> nameTable;//指针的vector
sort(nameTable.begin(), nameTable.end(),//✖,<产生未定义行为
[](string *a, string *b) {return a<b;});
//✔,标准库规定指针的less是定义良好的
sort(nameTable.begin(), nameTable.end(), less<string*>());
③可调用对象与function
C++中有几种可调用的对象:函数、函数指针、lambda表达式、bind创建的对象、重载了函数调用运算符的类。
和其他对象一样,可调用对象也有类型。例如,每个lambda有他自己唯一的类类型;函数和函数指针由其返回值类型和实参类型决定。
然而,两个不同类型的可调用对象可能共享同一种调用形式。调用形式指明了调用返回类型以及传递给调用的实参类型。一种调用形式对应一个函数类型。
int(int, int)//是一个函数类型,接受两个int, 返回一个int
不同类型可能具有相同的调用形式:对于几个可调用对象共享一种调用形式的情况,有时会把他门看成具有相同的类型。
下面这些可调用对象,尽管类型各不相同,但是共享一种调用形式int(int,int)
我们可能希望使用这些可调用对象构建一个简单的计算机器。所以,需要定义一个函数表用于存储指向这些可调用对象的指针。当程序需要执行某个特定操作时,从表中查找该调用的函数。
在C++语言中,可以通过map来实现。使用一个表示运算符号的string对象作为关键字;使用实现运算符的函数作为值。
假定所有函数相互独立,且只处理关于int的二元运算。则map可定义成如下形式:
//普通函数
int add(int i, int j)
{
return i+j;
}
//lambda,其产生一个未命名的函数对象类
auto mod=[](int i, int j)
{
return i%j;
};
//函数对象类
struct divides
{
int operator()(int denominator, int divisor)
{
return denominator/divisor;
}
};
//构建从运算符号到函数指针的映射关系,其中函数接受两个int, 返回一个int
map<string, int(*)(int, int)> binops;
int main(int argc, char *argv[])
{
binops.insert({"+", add});
//但我们不能将mod或divide存入binops
//问题在与mod是lambda,每个lambda都有他自己的类类型,与
//存储在binops中的值的类型不匹配
binops.insert({"%", mod});//✖,mod不是一个函数指针
}
标准库function类型:我们可使用一个名为function的新的标准库类型解决上述问题,function定义在functional头文件中。
function是一个模板,当创建一个具体的function类型时必须提供额外信息。在此例中,额外信息是指该function类型能够表示的对象的调用形式。
class Sales_data
{
};
//普通函数
int add(int i, int j)
{
return i+j;
}
//lambda,其产生一个未命名的函数对象类
auto mod=[](int i, int j)
{
return i%j;
};
//函数对象类
struct divide
{
int operator()(int denominator, int divisor)
{
return denominator/divisor;
}
};
function<int(int, int)> f1=add;//函数指针
function<int(int, int)> f2=divide();//函数对象类的对象
function<int(int, int)> f3=[](int i, int j){return i*j;};//lambda
map<string, function<int(int, int)>> binops=
{
{"+", add},//函数指针
{"-", minus<int>()},//标准库函数对象
{"/", divide()},//用户定义的函数对象
{"*", [](int i, int j){return i*j;}},//未命名的lambda
{"%", mod}//命名的lambda
};
int main(int argc, char *argv[])
{
cout<<f1(10, 5)<<endl;//调用add(10, 5)
binops["+"](10, 5);//调用add(10, 5)
/**
* 我们不能(直接)将重载函数的名字存入fucntion类型的对象中,
* 因为无法区分是哪个add
* 比如若多个重载的add.
* 增加:Sales_data add(const Sales_data&, const Sales_data&);
*
* 解决这个问题的一个途径是存储函数指针而非函数的名字
*/
int (*fp)(int, int)=add;//指针所指的add是接受两个Int的版本
binops.insert({"+", fp});
//同样,也可以用lambda消除二义性
binops.insert({"+", [](int a, int b){return add(a, b);}});
}
新版标准库中的function与旧版本中的unary_function和binary_function没有关联,后两个类已经被bind代替。
9. 重载、类型转换与运算符
第六章第5第④中,看到由一个实参调用的非显式构造函数定义了一种隐式的类型转换,这种构造函数将实参类型的对象转换成类类型。我们同样能定义对于类类型的类型转换,通过定义类型转换运算符可以做到这一点。转换构造函数和类型转换运算符共同定义了类类型转换,这样的转换有时也被称作用户定义的类型转换。
①类型转换运算符
类型转换运算符是类的一种特殊成员函数,它负责将一个类类型的值转换为其他类型。
operator type() const;
/**
* 其中type表示某种类型。类型转换运算符可以面向任意类型(除了void外)进行定义,
* 只要该类型能作为函数的返回类型。因此,我们不允许转换成数组或者函数类型,但
* 允许转换成指针(包括数组指针及函数指针)或者引用类型。
*
* 类型转换运算符既没有显式的返回类型,也没有形参,而且必须定义成类的成员函数。
* 类型转换运算符通常不改变待转换对象的内容。因此,类型转换运算符一般被定义成const成员
*/
定义含有类型转换运算符的类:
/**
* 既定义了向类类型的转换,也定义了从类类型
* 向其他类型的转换。
* 其中,构造函数将算术类型的值转换成smallInt对象
* 而类型转换运算符将SmallInt对象转换成int
*/
class SmallInt
{
public:
SmallInt(int i=0):val(i)
{
if(i<0||i>255)
throw out_of_range("bad value");
}
operator int() const
{
return val;
}
private:
size_t val;
};
int main(int argc, char *argv[])
{
SmallInt si;
si=4;//首先将4隐式转换(构造函数)成SmallInt, 然后调用SmallInt::operator=
si+3;//首先将si隐式的转换(转换运算符)成int, 然后执行整数的加法
/**
* 尽管编译器一次只能执行一个用户定义的类型转换,但是隐式的用户
* 定义类型转换可以置于一个标准(内置)类型转换之前或之后,并与其一起使用。
* 因此,我们可以将任何算术类型传递给SmallInt的构造函数。
* 类似的,我们也能使用类型转换运算符将一个SmallInt对象转换成int,
* 然后将所得的int转换成任何其他算术类型
*/
//内置类型转换将double实参转换成int
SmallInt si1=3.14;//调用SmallInt(int)构造函数
//SmallInt的类型转换运算符将si转换成int
si1+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不是一个指针
};
类型转换运算符可能产生意外结果:在实践中,类很少提供类型转换运算符。在大多数情况下,若类型转换自动发生,用户可能会感觉比较意外,而不是 感觉受到了帮助。然而这条经验法则存在一种例外情况:对于类来说,定义向bool的类型转换还是比较普遍。
在C++标准的早期版本中,若类向定义一个向bool的类型转换,则常常遇到一个问题:因为bool是一种算术类型,所以类类型的对象转换成bool后能被用在任何需要算术类型的上下文中。这样的类型转换可能引发意想不到的结果,特别当istream含有向bool的类型转换时,下面代码仍将编译通过:
int i=42;
cin<<i;//若想bool的类型转换不是显式的,则该代码在编译器看来将是合法的
/**
* 这段程序试图将输出运算符作用于输入流。因为istream本身并没有定义<<
* 所以本来代码应该产生错误。然而,该代码能使用istream的bool类型转换运算符
* 将cin转换成bool, 而这个bool值接着会被提升成int并用作内置的左移运算符
* 的左侧运算对象,这样一来,提升后的bool值最终会被左移42个位置
*/
显式的类型转换运算符:
为了防止上述情况发生,C++11新标准引入了显式的类型转换运算符
class SmallInt
{
public:
SmallInt(int i=0):val(i)
{
if(i<0||i>255)
throw out_of_range("bad value");
}
//编译器不会自动执行这一类型转换
explicit operator int() const
{
return val;
}
private:
size_t val;
};
int main(int argc, char *argv[])
{
SmallInt si=3;//✔,SmallInt的构造函数不是显式的
si+3;//✖,此处需要隐式的类型转换,但类的运算符是显式的
static_cast<int>(si)+3;//✔,显式的请求类型转换
}
当类型转换运算符是显式的时,我们也能执行类型转换,不过必须是显式的强制类型转换才可以。
该规定存在一个例外,即如果表达式被用作条件,则编译器会将显式的类型转换自动应用于它。即,当表达式出现在下列位置时,显式的类型转换将被隐式的执行。
[1] if、while、do语句的条件部分
[2] for语句头的条件表达式
[3] 逻辑非运算符(!)、逻辑或(||)、逻辑与(&&)运算符的运算对象
[4] 条件运算符(?:)的条件表达式
转换为bool:
向bool的类型转换通常用在条件部分,因此operator bool一般定义成explicit的。
在标准库早期版本中,IO类型定义了向void*的转换规则,以求避免上述提到的问题。在C++11标准下,IO标准库通过定义一个向bool的显式类型转换实现同样的目的。
无论什么时候在条件中使用流对象,都会使用为IO类型定义的operator bool:
/**
* while语句的条件执行输入运算符,它负责将数据读入到value并返回cin.
* 为了对条件求值,cin被istream operator bool类型转换函数隐式的
* 执行了转换。
*
* 若cin的条件状态是good, 则该函数返回为真;否则返回为假
*/
while(cin>>value)
②避免有二义性的类型转换
如果类中包含一个或多个类型转换,则必须确保在类类型和目标类型之间只存在唯一一种转换方式。否则,编写的代码很可能会具有二义性。
在两种情况下可能产生多重转换路径。第一种情况是两个类提供相同的类型转换:例如,当A类定义了一个接受B类对象的转换构造函数,同时B类定义了一个转换目标是A类的类型转换运算符时,我们就说他们提供了相同的类型转换。
第二种情况是类定义了多个转换规则,而这些转换涉及的类型本身可以通过其他类型转换联系在一起。例如,算术运算符,对于某个给定的类来说,最好只定义最多一个与算术类型有关的转换规则。
通常情况下,不要为类定义相同的类型转换,也不要在类中定义两个及两个以上转换源或转换目标是算术类型的转换。
实参匹配和相同的类型转换:
在下面的例子中,定义了两种将B转换成A的方法:一种使用B的类型转换运算符、另一种使用A的以B为参数的构造函数
struct B;
struct A
{
A()=default;
A(const B&);//把一个B转换成A
};
struct B
{
operator A() const;//也是把一个B转换成A
};
A f(const A&);
B b;
A a=f(b);//二义性错误:含义是f(B::operator A())
//还是,f(A::A(const B&))?
//若确实想执行上述调用,则要显式调用类型转换运算符,或转换构造函数
A a1=f(b.operator A());
A a2=f(A(b));
//我们无法使用强制类型转换解决二义性问题,因为强制类型转换本身也面临二义性
二义性与转换目标为内置类型的多重类型转换:
若类定义一组类型转换,他们的转换源(或者转换目标)类型本身可以通过其他类型转换联系在一起,会产生二义性。例如:类当中定义了多个参数都是算术类型的构造函数,或者转换目标都是算术类型的类型转换运算符。
struct A
{
A(int=0);//最好不要创建两个转换源都是算术类型的类型转换
A(double);
operator int() const;//最好不要创建两个转换对象都是算术类型的类型转换
operator double() const;
};
int main(int argc, char *argv[])
{
void f2(long double);
A a;//a->int、double->long double, 编译器无法区分哪个更好
f2(a);//二义性错误,含义是:f(A::operator int())
//还是:f(A::operator double())
long lg;//lg->double;lg->int, 编译器无法区分哪个更好
A a2(lg);//二义性错误:含义是A::A(int)还是A::A(double)?
/**
* 调用f2及初始化a2之所以会产生二义性,根本原因是他们所需的标准类型转换级别一致
* 当使用自定义的类型转换时,若转换过程包含标准类型转换,则标准类型转换的级别决定
* 编译器选择最佳匹配的过程
*/
short s=42;
//把short提升为int 优于把short转化成double
A a3(s);
}
重载函数与转换构造函数:
当调用重载的函数时,从多个类型转换中进行选择会更加复杂。若两个或多个类型转换都提供了同一种可行匹配,则这些类型转换一样好。
例如,当几个重载函数的参数分属不同的类类型时,若这些类恰好定义了同样的转换构造函数,则二义性问题将进一步提升
struct C
{
C(int);
};
struct D
{
D(int);
};
void manip(const C&);
void manip(const D&);
manip(10);//二义性错误:含义是manip(C(10)), 还是manip(D(10))
//显式构造正确类型去消除二义性
manip(C(10));
重载函数与用户定义的类型转换:
当调用重载函数时,若两个(或多个)用户定义的类型转换都提供了可行匹配,则认为这些类型转换一样好。在这个过程中,我们不会考虑任何可能出现的标准类型转换的级别。只有当重载函数能通过同一个类型转换函数得到匹配时,才会考虑其中出现的标准类型转换。
struct C
{
C(int);
};
struct E
{
E(double);
};
void manip(const C&);
void manip(const E&);
//二义性错误:两个不同的用户定义的类型转换都能用在此处
manip(10);//manip(C(10))还是manip(E(double(10)))
在调用重载函数时,若需要额外的标准类型转换,则该转换的级别只有当所有可行函数都请求同一个用户定义的类型转换时才有用。若所需的用户定义的类型转换不只一个,则该调用具有二义性。
struct C
{
C(int);
C(double);
};
void manip(const C&);
//✔
manip(10);
③函数匹配与重载运算符
重载的运算符也是重载的函数。因此,通用的函数匹配规则同样适用于判断在给定的表达式中到底应该使用内置运算符还是重载的运算符。当运算符函数出现在表达式中时,候选函数集的规模要比我们使用调用运算符调用函数时更大。若a是一种类型,则表达式a sym b可能是:
a.operatorsym(b);//a有一个operatorsym成员函数
operatorsym(a, b);//operatorsym是一个普通函数
和普通函数调用不同,不能通过调用的形式来区分当调用的是成员函数还是非成员函数。
当我们使用重载运算符作用于类类型的运算对象时,候选函数中包含该运算符的普通非成员版本和内置版本。此外,若左侧运算对象是类类型,则定义在该类中的运算符的重载版本也包含在候选函数内。
当调用一个命名的函数时,具有该名字的成员函数和非成员函数不会彼此重载,因为我们用来调用命名函数的语法形式对于成员函数和非成员函数是不相同的。
当通过类类型的对象(或者该对象的指针及引用)进行函数调用时,只考虑该类的成员函数。
当在表达式使用重载的运算符时,无法判断正在使用的是成员函数还是非成员函数。表达式中运算符的候选函数集既包括成员函数,也包括非成员函数。
class SmallInt
{
friend SmallInt operator+(const SmallInt&, const SmallInt&);
public:
SmallInt(int=0);//转换源为int的类型转换
operator int() const//转换目标为int的类型转换
{
return val;
}
private:
size_t val;
};
/**
* 可以使用这个类将两个SmallInt相加,
* 但若试图执行混合模式的算术运算,会遇到二义性
*
* 第二条加法:我们可以把0转换为SmallInt, 然后使用SmallInt的+
* 或者把s3转换成int, 然后对于两个int执行内置加法运算
*/
SmallInt s1, s2;
SmallInt s3=s1+s2;//使用重载的operator+
int i=s3+0;//二义性错误
十四. 面向对象程序设计
1. OOP:概述
面向对象程序设计:核心思想是数据抽象、继承和动态绑定。通过抽象,可以将类的接口与实现分离;使用继承,可定义相似的类型,并对其相似关系建模;使用动态绑定,可在一定程度上忽略相似类型的区别。
继承:通过继承联系在一起的类构成一种层次关系。层次关系的根部有一个基类,其他类则直接或间接从基类继承而来,这些继承的类称为派生类。基类负责定义在层次关系中所有类共同拥有的成员。每个派生类定义各自特有的成员。
在C++语言中,基类将类型相关的函数与派生类不做改变直接继承的函数区别对待。对于某些函数,基类希望他的派生类各自定义适合自身的版本此时的基类就将这些函数声明为虚函数。派生类必须通过类派生列表明确指出他是从哪个类继承而来的。类派生列表的形式是:首先是一个冒号,后面紧跟以逗号分隔的基类列表,其中每个基类前面可以有访问说明符:
class Quote
{
public:
string isbn() const;
virtual double net_price(size_t n) const;//虚函数
};
class Bulk_quote:public Quote
{
public:
double net_price(size_t) const override;
};
因为Bulk_quote在他的派生列表中使用了public关键字,因此我们完全可以把Bulk_quote对象当成Quote对象使用。
派生类必须在其内部对所有重新定义的虚函数进行声明,派生类可以在这样的函数之前加上virtual关键字,但不是必须的。C++11规定:允许派生类显式的注明它将使用哪个成员函数改写基类的虚函数,具体措施是在该函数的形参列表后加override关键字。
动态绑定:
通过使用动态绑定,同一段代码能分别处理Bulk_quote和Quote对象
double print_total(const Quote &item)//根据实际传入的对象类型,决定调用Bulk_quote还是Quote
{
}
在C++中,当我们使用基类的引用(或指针),调用一个虚函数时,将发生动态绑定。
2. 定义基类和派生类
①定义基类
class Quote
{
public:
Quote()=default;
Quote(const string &book, double sale_price)
:bookNo(book), price(sale_price) {}
string isbn() const {return bookNo;};//直接继承而不需要改变的函数
virtual double net_price(size_t n) const//虚函数,希望派生类改变
{
return n*price;
}
virtual ~Quote()=default;
private:
string bookNo;//书号
protected:
double price=0.0;//不打折的价格
};
基类通过在其成员函数前加上virtual关键字使得该函数执行动态绑定。virtual只能出现在类内声明语句前。若基类把一个函数声明成虚函数,则在派生类中该函数隐式的也是虚函数。
成员函数若没被声明成虚函数,则其解析过程发生在编译时,而非运行时。
访问控制与继承:
派生类可以继承定义在基类中的成员,但是派生类的成员函数不一定有权访问从基类继承而来的成员。和其他使用基类的代码一样,派生类能访问公有成员,而不能访问私有成员。有时,基类希望他的派生类有权访问该成员,同时希望其他用户访问该成员。我们使用受保护访问运算符说明这样的成员。
②定义派生类
派生类必须通过使用类派生列表。明确指出它是从哪个(哪些)基类继承而来的。类派生列表的形式是:首先是一个冒号,后面紧跟以逗号分隔的基类列表,其中每个基类前面可以有以下三种访问说明符中的一个:public, protected, private。
派生类必须将其继承而来的成员函数中需要覆盖的那些重新声明。
/**
* 该函数继承了Quote的isbn、bookNo、price等
*/
class Bulk_quote:public Quote
{
public:
Bulk_quote()=default;
Bulk_quote(const string&, double, size_t, double );
double net_price(size_t) const override;//覆盖基类的函数
private:
size_t min_qty=0;//适用折扣政策的最低购买量
double discount=0.0;//折扣额
};
若一个派生是公有的,则基类的公有成员也是派生类接口的组成部分。我们能将公有派生类型的对象绑定到基类的引用或指针上。
派生类中的虚函数:派生类经常覆盖虚函数。若派生类没有覆盖基类中的某个虚函数,则派生类会直接继承其在基类中的版本。
派生类可在它覆盖的函数前加virtul关键字,但不是必须的。允许派生类显式的注明它将使用哪个成员函数改写基类的虚函数,具体措施是在该函数的形参列表后、const成员函数的const关键字后、引用成员函数的引用限定符后加override关键字。
派生类对象及派生类向基类的类型转换:
一个派生类对象包括多个组成部分:一个含有派生类自己定义的(非静态)成员的子对象,以及一个与该派生类继承的基类对应的子对象。若有多个基类,那么这样的子对象也有多个。因此一个Bulk_quote对象包含四个元素:他从Quote继承而来的bookNo和price成员,以及Bulk_quote自己定义的min_qty和discount成员。
因为派生类对象含有与其基类对应的组成部分,所以可把派生类对象当成基类对象使用,也能将基类的指针和引用绑定到派生类对象上的基类部分。
Quote item;//基类对象
Bulk_quote bulk;//派生类对象
Quote *p=&item;//p指向Quote对象
p=&bulk;//p指向bulk的Quote部分
Quote &r=bulk;
这种转换称为派生类到基类的类型转换(隐式执行)。意味着可以使用动态绑定。
派生类构造函数:
尽管在派生类对象中含有从基类继承而来的成员,但是派生类并不能直接初始化这些成员。必须使用基类的构造函数,初始化基类部分。(每个类控制他自己的成员初始化过程)。
派生类对象的基类部分以及派生类自己的数据成员都是在构造函数的初始化阶段执行初始化操作的。派生类构造函数通过构造函数初始化列表将实参传递给基类构造函数。
class Quote
{
public:
Quote()=default;
Quote(const string &book, double sale_price)
:bookNo(book), price(sale_price) {}
string isbn() const {return bookNo;};//直接继承而不需要改变的函数
virtual double net_price(size_t n) const//虚函数,希望派生类改变
{
return n*price;
}
virtual ~Quote()=default;
private:
string bookNo;//书号
protected:
double price=0.0;//不打折的价格
};
class Bulk_quote:public Quote
{
public:
Bulk_quote()=default;
/**
前两个参数传递给基类的构造函数,初始化基类部分
后两个参数初始化派生类部分。
除非特别指出,否则派生类对象的基类部分会执行默认初始化。
**/
Bulk_quote(const string &book, double p, size_t qty, double disc)
:Quote(book, p), min_qty(qty), discount(disc) {}
double net_price(size_t) const override;//覆盖基类的函数
private:
size_t min_qty=0;//适用折扣政策的最低购买量
double discount=0.0;//折扣额
};
派生类使用基类成员:
double net_price(size_t n) const
{
if(n>=min_qty) return (1-discount)*n*price;
return n*price;
}
后面会讨论作用域,目前只需了解,派生类的作用域嵌套在基类的作用域内。
每个类负责自己的接口,因此最好不要直接初始化基类的成员,而是使用基类的构造函数。
继承与静态成员:
若基类定义了一个静态成员,则在整个继承体系中,只存在该成员的唯一定义。不论从基类中派生出多少个派生类,对于每个静态成员来说都只存在唯一的实例。
class Base
{
public:
static void statmem();
};
class Derived:public Base
{
void f(const Derived&);
};
/**
* 静态成员遵循通用的访问规则,若基类中的成员是private的
* 则派生类无权访问它。假设某静态成员是可访问的,我们既能通过
* 基类访问它,也能通过派生类访问它。
*/
void Derived::f(const Derived &derived_obj)
{
Base::statmem();//✔,Base定义了statmem
Derived::statmem();//✔,Derived继承了statmem
derived_obj.statmem();//Derivec对象访问
statmem();//this对象访问
}
派生类的声明:
Bulk_quote bulk;
被用作基类的类:
若想某个类用作基类,则该类必须已经定义而非仅声明。(一个类不能派生它本身)
最终派生类包含直接基类的子对象,也包含间接基类(隔一个)的子对象。
防止继承的发生:
我们不想一个类被其他类继承。C++11提供了一种方法,在类名后跟一个关键字final。
class NoDerived final {};//不能作为基类
class Base{};
class Last final:Base {};//Last不能作为基类
③类型转换与继承
通常情况下,若想把引用或指针绑定到一个对象上,则引用或指针的类型应与对象的一致,或者对象的类型含有一个可接受的const转换规则。
存在继承关系的类是一个重要例外:可以将基类的指针(含智能指针)或引用绑定到派生类对象上。
静态类型与动态类型:
表达式的静态类型在编译时总是已知的,他是变量声明或表达式生成的类型;动态类型则是变量或表达式表示的内存中的类型。动态类型直到运行时才可知。(基类的引用或指针绑定到派生类)
若表达式既不是指针也不是引用,则它的动态类型与静态类型永远一致。(Quote类型的变量永远是一个Quote对象)。
不存在从基类向派生类的隐式类型转换...:
派生类可向基类转换,因为每个派生类对象都包含基类部分,而基类的指针或引用可以绑定到该基类部分上。
基类不能向派生类转换,因为基类可能不含派生类的某些成员。
Quote base;
Bulk_quote* bulkP=&base;//✖,不能将基类转换成派生类
Bulk_quote& bulkRef=base;//✖,同上
Bulk_quote bulk;
Quote* itemP=&bulk;//✔,动态类型是Bulk_quote,静态类型是Quote
Bulk_quote *bulkP=itemP;//✖,不能将基类转换成派生类
编译器在编译时,无法确定某个特定的转换在运行时是否安全,这是因为编译器只能检查指针或引用的静态类型来推断转换是否合法。
若基类中含有一个或多个虚函数,可以使用dynamic_cast请求一个类型转换,该转换的类型检查将在运行时执行。
若已知某个基类向派生类的转换是安全的,则可使用static_cast强制覆盖掉编译器的检查工作。
...在对象之间不存在类型转换:
派生类向基类的自动类型转换只对指针或引用类型有效,在派生类型和基类类型间不存在转换。有时我们希望其能转换,实际过程是调用函数。
初始化和赋值,实际上是调用某些函数。
当用一个派生类对象为为一个基类对象初始化或赋值时,只有该派生类对象中的基类部分会被拷贝、移动和赋值,它的派生类部分会被忽略掉。
Bulk_quote bulk;//派生类对象
Quote item(bulk);//使用Quote(const Quote&)构造函数,参数是引用(隐式转换)
item=bulk;//调用,Quote::operator=(const Quote&)
3. 虚函数
在C++语言中,当我们使用基类的引用或指针调用一个虚成员函数时会执行动态绑定。因为我们直到运行时才知道调用了哪个版本的虚函数,所以所有虚函数必须有定义。
对虚函数的调用可能在运行时才被解析:
动态绑定,只有通过引用或指针调用虚函数时才会发生。
当通过一个具有普通类型(非引用、指针)的表达式调用虚函数时,在编译时就会将调用的版本确定下来(静态类型的类型)。
派生类中的虚函数:
当我们在派生类中覆盖了某个虚函数时,可以再次使用virtual关键字指出该函数的性质。但这么做非必须,因为一个函数被声明成虚函数,则在其所有派生类中都是虚函数。
一个派生类的函数如果覆盖了某个继承而来的虚函数,它的形参类型必须和被覆盖的基类函数的类型完全一致。
同样,派生类中虚函数的返回类型也必须与基类函数一致。该规则存在一个例外,当类的虚函数返回类型是类本身的指针或引用时,上述规则无效。若D由B派生得到,则基类的虚函数可以返回B*, 而派生类返回的类型可以为D*, 只不过这样的返回类型要求从D到B的类型转换是可访问的。
final和override说明符:
派生类若定义了一个函数与基类中虚函数的名字相同但形参列表不同,这是合法的。编译器将认为新定义的这个函数与基类中原有的函数是相互独立的。派生类中的函数并没有覆盖掉基类中的版本。根据编程习惯,通常认为是将形参写错了。
C++11中,我们可以使用override关键字来说明派生类中的虚函数(为了排错)。
final和override出现在形参列表(包括任何const或引用修饰符)以及尾置返回类型之后。
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;//✖,B中f3()不是虚函数
void f4() override;//✖,B中f4
};
//还可以将某个函数指定为final, 这样的函数不允许被覆盖
struct D2:B
{
void f1(int) const final;//不允许后续的其他类覆盖f1(int)
};
struct D3:D2
{
void f2();//✔,覆盖从间接基类B继承来的f2()
void f1(int) const;//✖,D2已经将f1(int)声明成final
};
虚函数与默认实参:
虚函数也可以拥有默认实参。若某次函数调用使用了默认实参,则该实参值由本次调用的静态类型决定。
即,如果我们通过基类的引用或指针调用函数,则使用基类中定义的默认实参,即使实际运行的是派生类中的函数版本也是如此。此时,传入派生类函数的将是基类函数定义的默认实参。
回避虚函数机制:
在某些情况下,我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特点版本。使用域作用运算符可以实现这一目的。
通常是当一个派生类的虚函数,调用它覆盖的基类的虚函数版本时,使用回避虚函数机制。否则会造成递归调用自己。
4. 抽象基类
当有多种不同的折扣策略时,我们可以定义一个名为Disc_quote来支持不同的折扣策略。其他表示特定策略的类将继承Disc_quote,并通过覆盖Disc_quote中的虚函数net_price来完成自己的策略。
Disc_quote和具体的策略无关,所以其net_price没有具体含义,若无这个函数,将使用Quote中的net_price, 这会导致编写出无意义的代码。
关键问题不是如何定义net_price, 而是根本不希望用户创建一个Disc_quote对象。Disc_quote表示的是打折的通用概念,而非具体的策略。
可以将net_price定义为纯虚函数。
纯虚函数没有实际意义,无须定义。通过在函数体的位置(声明语句的分号之前)书写=0,就可以将一个虚函数说明为纯虚函数。=0只能出现在类内部的虚函数声明语句处。
也可以为纯虚函数提供定义,但是必须在类外部。
class Quote
{
public:
Quote()=default;
Quote(const string &book, double sale_price)
:bookNo(book), price(sale_price) {}
string isbn() const {return bookNo;};//直接继承而不需要改变的函数
virtual double net_price(size_t n) const//虚函数,希望派生类改变
{
return n*price;
}
virtual ~Quote()=default;
private:
string bookNo;//书号
protected:
double price=0.0;//不打折的价格
};
class Disc_quote:Quote
{
public:
Disc_quote()=default;
Disc_quote(const string &book, double price, size_t qty, double disc)
:Quote(book, price), quantity(qty), discount(disc){}
double net_price(size_t) const=0;//纯虚函数
protected:
size_t quantity=0;
double discount=0.0;
};
含有纯虚函数的类是抽象基类:
含有(或者未经覆盖直接继承)纯虚函数的类是抽象基类。抽象基类负责定义接口,后续的其他类覆盖接口。不能(直接)创建一个抽象基类的对象。可以定义Disc_quote类的派生类对象,前提是这些类覆盖了net_price函数,否则这些派生类仍为抽象基类。
派生类构造函数只初始化它的直接基类:
class Quote
{
public:
Quote()=default;
Quote(const string &book, double sale_price)
:bookNo(book), price(sale_price) {}
string isbn() const {return bookNo;};//直接继承而不需要改变的函数
virtual double net_price(size_t n) const//虚函数,希望派生类改变
{
return n*price;
}
virtual ~Quote()=default;
private:
string bookNo;//书号
protected:
double price=0.0;//不打折的价格
};
class Disc_quote:Quote
{
public:
Disc_quote()=default;
Disc_quote(const string &book, double price, size_t qty, double disc)
:Quote(book, price), quantity(qty), discount(disc){}
double net_price(size_t) const=0;
protected:
size_t quantity=0;
double discount=0.0;
};
class Bulk_quote:public Disc_quote
{
public:
Bulk_quote()=default;
Bulk_quote(const string &book, double price, size_t qty, double disc)
: Disc_quote(book, price, qty, disc) {}
//覆盖基类中的版本,以实现新的策略
double net_price(size_t) const override;
};
5. 访问控制与继承
每个类控制自己的成员初始化过程,每个类还分别控制着其成员对于派生类来说是否可访问。
受保护的成员:
一个类使用protected关键字来声明那些它希望派生类分享,但是不想被其他公共访问使用的成员。
[1] 和私有成员类似,受保护的成员对于类的用户来说是不可访问的。
[2] 和公有成员类似,受保护的成员对于派生类的成员和友元来说是可访问的。
[3] 派生类的成员或友元只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中的受保护成员没有任何访问特权。
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;
};
//✔,clobber能正常访问Sneaky的私有和保护成员
void clobber(Sneaky &s)
{
s.j=s.prot_mem=0;
}
//✖,clobber不能访问Base的保护成员
void clobber(Base &b)//若这样合法,而clobber不是Base的友元,规避了Base的保护
{
b.prot_mem=0;
}
公有、私有和受保护继承:
某个类对其继承而来的成员的访问权限受到两个因素影响:一是在基类中该成员的访问说明符,二是在派生类的派生列表中的访问说明符。
class Base
{
public:
void pub_mem();
protected:
int prot_mem;
private:
char priv_mem;
};
struct Pub_Derv:public Base
{
//✔,派生类能访问protected成员
int f()
{
return prot_mem;
}
//✖,private对于派生类来说是不可访问的
char g()
{
return priv_mem;
}
};
struct Priv_Derv:private Base
{
//private不影响派生类访问权限
int f1() const
{
return prot_mem;
}
};
派生访问说明符对于派生类的成员(以及友元)能否访问其直接基类的成员没什么影响。对基类成员的访问权限只与基类中的访问说明符有关。
派生访问说明符的目的是控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限。
d1中继承的成员是公有的,成员遵循原访问说明符。在d2中,Base的成员是私有的,不能调用。
Pub_Derv d1;//继承自Base的成员是public的
Priv_Derv d2;//继承自Base的成员是private的
d1.pub_mem();//✔,pub_mem在d1中是public的
d2.pub_mem();//✖,pub_mem在d2中是private的
派生访问说明符还可以控制继承自派生类的新类的访问权限
class Base
{
public:
void pub_mem();
protected:
int prot_mem;
private:
char priv_mem;
};
struct Pub_Derv:public Base
{
//✔,派生类能访问protected成员
int f()
{
return prot_mem;
}
//✖,private对于派生类来说是不可访问的
char g()
{
return priv_mem;
}
};
struct Priv_Derv:private Base
{
//private不影响派生类访问权限
int f1() const
{
return prot_mem;
}
};
struct Derived_from_public:public Pub_Derv
{
//✔,Base::prot_mem在Pub_Derv中仍是protected的
int use_base()
{
return prot_mem;
}
};
struct Derived_from_private:public Priv_Derv
{
//✖,Base::prot_mem在Priv_Derv中仍是private的
int use_base()
{
return prot_mem;
}
};
假设还定义了一个名为Prot_Derv的类,它采用受保护继承。则Base的所有公有成员在新定义的类中都是受保护的。Prot_Derv的用户不能访问Pub_mem, 但是Prot_Derv的成员和友元可以访问继承而来的成员。
派生类向基类转换的可访问性:
派生类向基类的转换是否可访问由使用该转换的代码决定,同时派生类的派生访问说明符也会有影响。假定D继承自B
[1] 只有当D公有的继承B时,用户代码才能使用派生类向基类的转换;若D继承B的方式是受保护的或私有的,则用户代码不能使用该转换
[2] 无论D使用什么方式继承B,D的成员函数和友元都能使用派生类向基类的转换;派生类向其直接基类的类型转换对于派生类的成员和友元来说永远是可访问的。
[3] 若D继承B的方式是公有的或受保护的,则D的派生类的成员和友元可以使用D向B的类型转换;反之,若D继承B的方式是私有的,则不能使用。
对于代码中某个节点来说,若基类的公有成员是可访问的,则派生类向基类的类型转换也是可访问的;反之则不行。
-
友元和继承:
友元关系不能传递和继承。基类的友元在访问派生类时没有不具特殊性,派生类的友元也不能随意访问基类成员。
如前所述,每个类负责控制自己的成员访问权限。f3是正确的,Pal是Base的友元, 所以Pal能访问Base对象的成员,这种可访问性包括了Base对象内嵌在其他派生类对象中的情况。
class Base
{
friend class Pal;
public:
void pub_mem();
protected:
int prot_mem;
private:
char priv_mem;
};
class Sneaky:public Base
{
friend void clobber(Sneaky&);//能访问Sneaky::prot_mem
friend void clobber(Base&);//不能访问Base::prot_mem
int j;
};
class Pal
{
public:
int f(Base b)//✔,Pal是Base的友元
{
return b.prot_mem;
}
int f2(Sneaky s)
{
return s.j;//✖,Pal不是Sneaky的友元
}
int f3(Sneaky s)
{
return s.prot_mem;//✔,Pal是Base的友元
}
};
当一个类将另一个类声明为友元时,这种友元关系只对做出声明的类有效。对于原来的那个类来说,其友元的基类或派生类不具特殊访问能力。
class D2:public Pal//D2对Base的保护和私有成员不具特殊访问能力
{
};
改变个别成员的可访问性:
有时我们需要改变派生类继承的某个名字的访问级别,通过使用using声明可以达到这一目的。
因为Derived使用私有继承,所以继承来的size和n成员默认是私有的。但使用了using改变了这些成员的可访问性。size是公有,n是保护。
通过在类内部使用using声明语句,可以将该类的直接或间接基类中任何可访问成员(例如,非私有成员)标记出来。using声明语句中名字的访问权限由该using前的访问说明符来决定。
class Base
{
public:
size_t size() const
{
return n;
}
protected:
size_t n;
};
class Derived : private Base//私有继承
{
public:
using Base::size;//保持对象尺寸相关的成员访问级别
protected:
using Base::n;
};
默认的继承保护级别:
默认派生运算符也由定义派生类的关键字决定。默认情况下,使用class定义的派生类是私有继承的;使用struct关键字定义的派生类是公有继承的。
6. 继承中的类作用域
每个类定义自己的作用域,在这个作用域内定义类的成员。当存在继承关系时,派生类的作用域嵌套在基类作用域内。若一个名字在派生类的作用域中无法解析,则会在外层的基类中寻找改名字的定义。
在编译时进行名字查找:
一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的。即使静态类型和动态类型可能不一致,但是我们能使用哪些成员仍是由静态类型决定的。
名字冲突与继承:
和其他作用域一样,派生类也能重用定义在其直接基类或间接基类中的名字,此时定义在内存作用域(派生类)的名字将隐藏外层作用域(基类)的名字。
class Base
{
Base():mem(0) {}
protected:
int mem;
};
struct Derived:Base
{
Derived(int i):mem(i) {}//用i初始化Derived::mem, Base::mem默认初始化
int get_mem()//返回Derived::mem
{
return mem;
}
protected:
int mem;//隐藏基类中的mem
};
通过作用域运算符来使用隐藏的成员:
class Base
{
Base():mem(0) {}
protected:
int mem;
};
struct Derived:Base
{
Derived(int i):mem(i) {}//用i初始化Derived::mem, Base::mem默认初始化
int get_mem()//返回Derived::mem
{
return mem;
}
int get_base_mem()
{
return Base::mem;
}
protected:
int mem;//隐藏基类中的mem
};
一如往常,名字查找先于类型检查:
声明在内层作用域的函数并不会重载外层作用域的函数。因此,派生类中的函数也不会重载其基类中的成员。
若派生类中某个成员和基类中某个成员重名,派生类将会隐藏基类中的成员,即使派生类成员和基类成员的形参列表不一致。
class Base
{
public:
int memfcn();
};
struct Derived:Base
{
int memfcn(int);//隐藏基类的memfcn
};
int main()
{
Base b;
Derived d;
b.memfcn();//调用Base::memfcn
d.memfcn(10);//调用Derived::memfcn
d.memfcn();//✖,参数列表为空的memfcn被隐藏了
d.Base::memfcn();//✔
}
虚函数与作用域:
基类与派生类中的虚函数必须有相同的形参列表。若不相同,就无法通过基类的引用或指针调用派生类的虚函数了。
class Base
{
public:
virtual int fcn();
};
class D1:public Base
{
//隐藏基类的fcn,这个fcn不是虚函数, 因为形参列表不同, 此时有两个fcn,只不过D1中的被隐藏了
//D1继承了Base::fcn()的定义
int fcn(int);//形参列表与Base中的fcn不一致
virtual void f2();//新的虚函数,在Base中不存在
};
class D2:public D1
{
public:
int fcn(int);//非虚函数,隐藏了D1::fcn(int)
int fcn();//覆盖了Base的虚函数fcn
void f2();//覆盖了D1的虚函数f2
};
通过基类调用隐藏的虚函数:
虚函数调用则动态绑定,但是找名字是根据静态类型。
class Base
{
public:
virtual int fcn();
};
class D1:public Base
{
public:
//隐藏基类的fcn,这个fcn不是虚函数, 因为形参列表不同, 此时有两个fcn,只不过D1中的被隐藏了
//D1继承了Base::fcn()的定义
int fcn(int);//形参列表与Base中的fcn不一致
virtual void f2();//新的虚函数,在Base中不存在
};
class D2:public D1
{
public:
int fcn(int);//非虚函数,隐藏了D1::fcn(int)
int fcn();//覆盖了Base的虚函数fcn
void f2();//覆盖了D1的虚函数f2
};
int main()
{
Base bobj;
D1 d1obj;
D2 d2obj;
Base *bp1=&bobj, *bp2=&d1obj, *bp3=&d2obj;
bp1->fcn();//虚调用,运行时调用Base::fcn
bp2->fcn();//虚调用,运行时调用Base::fcn
bp3->fcn();//虚调用,运行时调用D2::fcn
D1 *d1p=&d1obj;
D2 *d2p=&d2obj;
bp2->fcn();//✖,Base没有名为f2的成员
d1p->f2();//虚调用,运行时调用D1::f2()
d2p->f2();//虚调用,运行时调用D2::f2()
}
覆盖重载的函数:
和其他函数一样,成员函数无论是否是虚函数都能被重载。派生类可以覆盖重载函数的0个或多个实例。若派生类希望所有的重载版本对于他来说都是可见的,那么他就需要覆盖所有的版本,或者一个也不覆盖。
有时一个类仅需覆盖重载集合中的一些而非全部函数,此时,如果我们不得不覆盖基类中的每一个版本的话,操作将极为繁琐。
一种解决方案是为重载的成员提供一条using声明语句(using Base::size;),这样就无需覆盖基类中的每一个重载版本了。using声明语句指定一个名字而不指定形参列表,所以一条基类成员函数的using声明语句就可以把该函数的所有重载实例添加到派生类作用域中。此时,派生类只需定义其特有的函数,而无需为继承而来的其他函数定义。
类内using声明的一般规则同样适用于重载函数的名字;基类函数的每个实例在派生类中必须是可访问的。对派生类没有重新定义的重载版本的访问,实际上是对using声明点的访问。
7. 构造函数与拷贝控制
和其他类一样,位于继承体系中的类也需要控制当其对象执行一系列操作时发生什么样的行为,包括创建、拷贝、移动、赋值、销毁。如果一个类(派生类和基类)没有定义拷贝控制操作,编译器将为他定义一个合成的版本。这个合成的版本也可以定义成删除的函数。
①虚析构函数
继承关系对基类拷贝控制最直接的影响是基类通常应该定义一个虚析构函数,这样就可以动态分配继承体系中的对象了。
当delete一个动态分配的对象的指针时将执行析构函数。若该指针指向继承体系中的某个类型,则有可能出现指针的静态类型与被删除对象的动态类型不符的情况。例如,delte一个Quote*类型的指针,该指针可能指向一个Bulk_quote类型的对象。此时,编译器必须清楚它应执行Bulk_quote的析构函数。通过在基类中将析构函数定义成虚函数,以确保执行正确版本。
若基类的析构函数不是虚函数,则delete一个指向派生类的基类指针将产生未定义行为。
class Quote
{
public:
//和其他函数一样,析构函数的虚属性也会被继承。因此,无论Quote
//的派生类使用合成的析构函数还是自定义的析构函数,都将是虚析构函数
//如果删除的是一个指向派生类对象的基类指针,则需要虚析构函数
virtual ~Quote()=default;//动态绑定析构函数
};
int main()
{
Quote *itemP=new Quote;//静态类型与动态类型一致
delete itemP;//调用Quote的析构函数
itemP=new Bulk_Quote;//
delete itemP;//调用Bulk_Quote的析构函数
}
之前介绍一个准则,若一个类需要析构函数,那么她也需要拷贝和赋值操作。基类的析构函数并不遵循上述准则,是一个重要例外。基类能将析构函数定义为虚函数,其内容为空,无法推断基类还需要拷贝和赋值运算符。
虚析构函数将阻止合成移动操作:
②合成拷贝控制与继承
基类或派生类的合成拷贝控制成员的行为与其他合成的构造函数、赋值运算符或析构函数类似:他们对类本身的成员依次进行初始化、赋值或销毁操作。此外,这些合成的成员还负责使用直接基类中对应的操作对一个对象直接基类部分进行初始化、赋值或销毁的操作。例如:
合成的Bulk_quote默认构造函数运行Disc_quote的默认构造函数,后者又运行Quote的默认构造函数(Bulk_quote继承Disc_quote继承Quote).
Quote默认构造函数将bookNo成员默认初始化为空字符串,同时使用类内初始化将price初始化为0
Quote构造函数完成后,继续执行Disc_quote的构造函数,它使用类内初始值初始化。
Disc_quote构造函数完成后,继续执行Bulk_quote的构造函数
拷贝构造函数也类似上面。每个类拷贝自己的成员。
无论基类成员是合成版本还是自定义版本都可以,唯一要求是可访问。
在Quote继承体系中,所有类都使用合成的析构函数。其中,派生类隐式的使用,而基类通过将虚析构函数定义成=default显式的使用。一如既往,合成的析构函数体是空的,其隐式的析构部分负责销毁类的成员。对于派生类的析构函数来说,它除了销毁派生类自己的成员来说,还负责销毁派生类的直接基类;该直接基类又销毁它自己的直接基类,依次类推。
派生类中删除的拷贝控制与基类的关系:
基类和派生类也能出于同样的原因将其合成的默认构造函数或者任何一个拷贝控制成员定义成被删除的函数。此外,某些定义基类的方式也可能导致有的派生类成员成为被删除的函数:
[1] 若基类中的默认构造函数、拷贝构造函数拷贝赋值运算符或析构函数是被删除的函数或不可访问,则派生类中对应的成员将是被删除的。因为编译器不能使用基类成员来执行派生类对象基类部分的构造、赋值或销毁操作。
[2] 如果在基类中有一个不可访问或删除掉的析构函数,则派生类中合成的默认和拷贝构造函数将是被删除的,因为编译器无法销毁派生类对象的基类部分。
[3] 编译器不会合成一个删除掉的移动操作。当我们使用=default请求一个移动操作时,如果基类中对应操作是删除的或不可访问的,那么派生类中该函数将是被删除的,原因是派生类对象的基类部分不可移动。同样的,若基类的析构函数是删除的或不可访问的,派生类的移动构造函数也将是被删除的。
class B
{
public:
B();
B(const B&)=delete;
//其他成员,不含移动构造函数
};
class D:public B
{
//没有声明任何构造函数
};
D d;//✔,D的合成默认构造函数使用B的默认构造函数
D d2(d);//✖,D的合成拷贝构造函数是被删除的
D d3(std::move(d));//✖,隐式的使用了D的被删除的拷贝构造函数
移动操作与继承:
大多数基类都会定义一个虚析构函数,因此默认情况下,基类不含合成移动操作,而且在它的派生类中也没有合成移动操作。
因为基类缺少移动操作会阻止派生类拥有自己的合成移动操作,所以确实需要移动操作时,应先在基类中定义。
Quote可以使用合成版本,前提是Quote必须显式的定义这些操作。一旦Quote定义了移动操作,就必须显式的定义拷贝操作。
除非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): Base(std::move(d)) {}//移动基类成员
// D(const D& d) {}//成员初始值,但是没有提供基类初始值,基类使用默认构造函数
};
派生类赋值运算符:
与拷贝和移动构造函数一样,派生类的赋值运算符,也必须显式的为其基类部分赋值
class Base
{
/**
* 略
*/
};
class D:public Base
{
public:
D& operator=(const D &rhs)
{
Base::operator=(rhs);//为其基类部分赋值
//按照过去方式为派生类赋值
return *this;
}
};
派生类析构函数:
派生类对象成员是隐式销毁的,其基类部分的成员也是隐式销毁。
和构造函数和赋值运算符不同的是,派生类析构函数只赋值销毁派生类自己分配的资源。
对象的销毁顺序与对象的创建顺序正好相反:派生类析构函数先执行,然后是基类的析构函数,以此类推。
class Base
{
/**
* 略
*/
};
class D:public Base
{
public:
//Base::~Base()被自动调用执行
~D() {}
};
在构造函数和析构函数中调用虚函数:
④继承的构造函数
在C++11标准中,派生类能够重用其直接基类定义的构造函数。尽管这些构造函数并非以常规方式继承而来的,我们姑且称为‘继承’的。一个类只初始化它的直接基类,一个类也只‘继承’其直接基类的构造函数。
类不能继承(和上面含义不同)默认、拷贝和移动构造函数。如果派生类没有直接定义这些函数,编译器将合成他们。
派生类‘继承’基类构造函数的方式是提供了一条注明了(直接)基类名的using声明语句。例如:
class Bulk_quote:public Disc_quote
{
public:
using Disc_quote::Disc_quote;//'继承'Disc_quote的构造函数
};
通常情况下,using声明语句只是令某个名字在当前作用域可见。而当作用域构造函数时,using声明语句将令编译器产生代码。对于基类的每个构造函数,编译器都生成一个与之对应的派生类构造函数。即,对于基类的每个构造函数,编译器都在派生类中生成一个形参列表完全相同的构造函数。
这些编译器生成的构造函数形如:
derived(parms):base(args){}
derived是派生类名字,base是基类名字,parms是构造函数的形参列表,args将派生类构造函数的形参传递给基类的构造函数。
在上面的Bulk_quote类中继承的构造函数等价于:
若派生类含有自己的数据成员,这些成员将被默认初始化。
Bulk_quote(const string &book, double price, size_t qty, double disc)
:Disc_quote(book, price, qty, disc) {}
继承的构造函数的特点:
8. 容器与继承
当我们使用容器存放继承体系中的对象时,通常必须采用间接存储的方式。因为不允许在容器中保存不同类型的元素。
在容器中放置(智能)指针而非对象:
当希望在容器中存放具有继承关系的对象时,实际上存放的是基类的指针。这些指针所指向的对象可能是基类类型,也可能是派生类类型。
//Quote是基类,Bulk_quote是派生类
vector<shared_ptr<Quote>> basket;
basket.push_back(make_shared<Quote>());
basket.push_back(make_shared<Bulk_quote>());//派生类智能指针被转换为基类智能指针
对于C++面向对象编程来说,一个悖论是无法直接使用面向对象编程。相反,必须使用指针或引用。因为指针会增加程序的复杂性,所以经常定义一些辅助类处理这种复杂情况。
class Quote
{
public:
//下面函数是为了,在屏蔽指针时,在包装类内部new对象
//new基类但对象是派生类,可能截断信息,所以采用虚函数clone方式动态绑定
//这样无需关心传来的是基类还是派生类
//该虚函数返回当前对象的一份动态拷贝
virtual Quote* clone() const &
{
return new Quote(*this);
}
virtual Quote* clone() &&
{
return new Quote(std::move(*this));
}
};
class Bulk_quote:public Quote
{
virtual Bulk_quote* clone() const &
{
return new Bulk_quote(*this);
}
virtual Bulk_quote* clone() &&
{
return new Bulk_quote(std::move(*this));
}
};
//封装类
class Basket
{
public:
//void add_item(const shared_ptr<Quote> &sale);
//隐藏指针,添加对象类型
void add_item(const Quote &sale)//拷贝给定对象
{
items.insert(shared_ptr<Quote>(sale.clone()));
}
void add_item(Quote&& sale)//移动给定对象
{
items.insert(shared_ptr<Quote>(std::move(sale).clone()));
}
private:
multiset<shared_ptr<Quote>> items;
};
十五. 模板与泛型编程
模板是C++泛型编程的基础。一个模板就一个创建类或函数的的蓝图。提供信息,使得蓝图转换为特定的类或函数,发生在编译时。
1. 定义模板
①函数模板
可以定义一个通用的函数模板,而不是为每个类型都定义一个函数。
模板定义以关键字template开始,后跟一个模板参数列表,这是一个逗号分隔的一个或多个模板参数,用小于号和大于号包围起来。
模板参数类似函数的参数列表,调用者提供实参初始化他们。
模板参数表示在类或函数中用到的类似或值。当使用模板时,我们(隐式或显式)的指定模板实参,将其绑定到模板参数上。
T表示一个类型,T的实际类型在编译时根据compare的使用情况来确定。
template<typename T>
int compare(const T &v1, const T &v2)
{
if(v1<v2) return -1;
if(v1>v2) return 1;
return 0;
}
实例化函数模板:
当我们调用一个函数模板时,编译器(通常)用函数实参来推断模板实参。用推断出的模板实参,实例化出一个特定的函数(T替换为模板实参)。
compare(0, 1);//T为int
int compare(const int &v1, const int &v2)
{
if(v1<v2) return -1;
if(v1>v2) return 1;
return 0;
}
vector<int> v1, v2;
compare(v1, v2);//T为vector<int>
int compare(const vector<int> &v1, const vector<int> &v2)
{
if(v1<v2) return -1;
if(v2<v1) return 1;
return 0;
}
模板类型参数:
compare有一个模板类型参数。一般来说,可以将类型参数看做类型说明符,就像内置类型或类类型说明符一样使用。特别的,类型参数可以用于指定返回类型或函数的参数类型,以及在函数体内用于变量声明或类型转换。
类型参数前,必须使用关键字class或typename, 在模板参数中class和typename没什么不同,但typename更直观。
//✔,返回类型和参数类型相同
template<typename T>
T foo(T* p)
{
T temp=*p;
return temp;
}
//✖,U前必须有class或typename
template<typename T, U>
T calc(const T&, const U&);
非类型模板参数:除了定义类型参数,还可以在模板中定义非类型模板参数。一个非类型参数表示一个值而非一个类型。通过一个特定的类型名而非关键字class或typename来指定非类型参数。
当一个模板被实例化时,非类型参数被一个用户提供或编译器推断出的值所代替。这些值必须是常量表达式,从而允许编译器在编译时实例化模板。
/**
* compare("hi", "mom");
* 编译器会使用字面常量的大小来代替N和M,从而实例化模板
*
* 编译器会在字符串字面常量的末尾插入一个空白字符作为终结符,
* 因此会实例化出以下版本
* int compare(const char (&p1)[3], const char (&p2)[4])
*/
template<unsigned N, unsigned M>
int compare(const char (&p1)[N], const char (&p2)[M])
{
return strcmp(p1, p2);
}
非类型参数可以是一个整型,或者是一个指向对象或函数类型的指针或引用(左值)。
绑定到非类型整型参数的实参必须是一个常量表达式。绑定到指针或引用非类型参数的实参必须具有静态的生存周期。不能用一个普通(非static)局部变量或动态对象作为指针或引用非类型模板参数的实参。指针参数也可以用nullptr或值为0的常量表达式来实例化。
在模板定义内,模板非类型参数是一个常量值。在需要常量表达式的地方,可以使用非类型参数。例如,指定数组大小。
inline和constexpr的函数模板:
函数模板可以声明为inline或constexpr的,如同非模板函数一样。inline或constexpr说明符放在模板参数列表之后,返回类型之前。
template<typename T>
inline T min(const T&, const T&);
编写类型无关的代码:
模板程序应该尽量减少对实参的要求。
最初的compare函数虽然简单,但是包含两个重要原则:函数参数是const的引用(解决实参无法拷贝的问题:IO类型,对于大型对象引用比拷贝更快);函数体中的条件判断仅使用<比较运算(对象可以仅支持<运算符,而不用支持>运算符)。
若考虑类型无关和可移植性,可能需要less来定义我们的函数。
原始版本用于指针时,且两个指针未指向相同的数组,则代码行为是未定义的。
template<typename T>
int compare(const T &v1, const T &v2)
{
if(less<T>()(v1, v2)) return -1;
if(less<T>()(v2, v1)) return 1;
return 0;
}
模板编译:
大多数错误在实例化期间报告:
当我们编写模板时,代码是不能针对特定类型的,但模板通常对其使用的类型有一些假设。比如compare要求,参数支持<操作。
②类模板
类模板是用来生成类蓝图的。与函数模板不同的是,编译器不能为类模板推断模板参数类型。例如,使用vector是在<>内提供额外信息,用来代替模板参数的实参列表。
定义类模板:
类似函数模板,类模板以关键字template开始,后跟模板参数列表。在类模板(及其)成员的定义中,将模板参数当作替身,代替使用模板时,用户需要提供的类型或值。
非依赖名称: 在模板中,非依赖名称是那些不依赖于模板参数的名称。这些名称在模板实例化之前就可以解析。
依赖名称: 依赖名称是那些依赖于模板参数的名称。这些名称在模板实例化之前不能完全解析,因为它们的定义可能会根据模板参数的不同而改变。
template<typename T>
class Blob
{
public:
typedef T value_type;
/**
* 在模板中,typename 关键字用于指示依赖名称是类型
* vector<T>::size_type 是一个依赖名称,因为它依赖于模板参数 T。
* 在这种情况下,编译器在模板实例化之前不知道 vector<T> 是什么,
* 也不知道 size_type 是 vector<T> 的成员类型。为了明确地告诉编译器 vector<T>::size_type
* 是一个类型而不是成员变量或其他东西,需要使用 typename 关键字。
*
* 这有助于编译器正确解析模板代码并避免潜在的编译错误。
*/
typedef typename vector<T>::size_type size_type;
//构造函数
Blob();
Blob(initializer_list<T> il);
//Blob中的元素数目
size_type size() const
{
return data->size();
}
bool empty() const
{
return data->empty();
}
//添加和删除元素
void push_back(const T &t)
{
data->push_back(t);
}
void push_back(T &&t)
{
data->push_back(std::move(t));
}
void pop_back();
//元素访问
T& back();
T& operator[](size_type i);
private:
shared_ptr<vector<T>> data;
//若data[i]无效,抛出msg
void check(size_type i, const string &msg) const;
};
实例化类模板:
当使用一个类模板时,必须提供额外信息,即:显式模板实参。
一个类模板的每个实例都是一个独立的类。
Blob<int> ia;
Blob<int> ia2={0, 1, 2, 3, 4};
在模板作用域中引用模板类型:
为了阅读模板类代码,应记住类模板的名字不是一个类型名。
一个类模板代码中如果使用了另一个模板,通常不将一个实际类型(或值)的名字用作其模板实参。相反的,我们通常将模板自己的参数当做被使用模板的实参。例如上面:shared_ptr<vector<T>> data;
-
类模板的成员函数:
我们既可以在类模板内部,也可以在类模板外部定义成员函数,且定义在类模板内部的成员函数被隐式的声明为内联函数。
类模板的成员函数本身是普通函数,但是类模板的每个实例都有自己的成员函数。类模板的成员函数具有和模板相同的模板参数。所以,定义在类模板外部的成员函数必须以关键字template开始,后跟模板参数列表。
Blob类模板的类外成员如下
template<typename T>
ret-type Blob<T>::member-name(parm-list)
template<typename T>
void Blob<T>::check(Blob::size_type i, const std::string &msg) const
{
if(i>=data->size())
throw out_of_range(msg);
}
template<typename T>
T& Blob<T>::back()
{
check(0, "sa");
return data->back();
}
template<typename T>
T& Blob<T>::operator[](Blob::size_type i)
{
check(i, "sda");
return (*data)[i];
}
template<typename T>
void Blob<T>::pop_back()
{
check(0, "sda");
data->pop_back();
}
Blob构造函数:
与其他任何定义在类模板外的成员一样,构造函数的定义要以模板参数开始
template<typename T>
Blob<T>::Blob():data(make_shared<vector<T>>())
{
}
template<typename T>
Blob<T>::Blob(initializer_list<T> il):data(make_shared<vector<T>>(il))//用il初始化此vector
{
}
类模板成员函数的实例化:
默认情况下,一个类模板的成员函数只有当程序使用它时才实例化(即使类已经实例化)。
Blob<int> a={0, 1, 2};//实例化Blob<int>和接受initializer_list<int>的构造函数
a[i]=-1;//实例化,Blob<int>::operator[](size_t)
在类代码内简化模板类名的使用:
当使用一个类模板类型时,必须提供模板实参。但这一个规则有个例外,在类模板自己的作用域中,可以直接使用模板名而不提供实参。
template<typename T>
class BlobPtr
{
public:
BlobPtr():curr(0) {}
BlobPtr(Blob<T> &a, size_t sz=0):wptr(a.data), curr(sz) {}
T& operator*() const
{
auto p=check(curr, "da");
return (*p)[curr];//*p为本对象指向的vector
}
/**
* 当处于一个类模板的作用域中,编译器处理自身引用时就好像
* 已经提供了与模板参数匹配的实参一样
* 即:等价于
* BlobPtr<T>& operator++();//前置运算符
BlobPtr<T>& operator--();
* @return
*/
//递增和递减
BlobPtr& operator++();//前置运算符
BlobPtr& operator--();
BlobPtr& operator++(int);//后置
private:
//若检查成功,check返回一个指向vector的shared_ptr
shared_ptr<vector<T>> check(size_t, const string&) const;
//保存一个weak_ptr,表示底层vector可能被销毁
weak_ptr<vector<T>> wptr;
size_t curr;//数组中当前的位置
};
在类模板外使用类模板名:
当在类模板外定义其成员时,并不在类的作用域中,直到遇到类名才表示进入类的作用域。
template<typename T>
BlobPtr<T>& BlobPtr<T>::operator++(int)//返回类型在类作用域外要指出T
{
BlobPtr ret=*this;//保存当前值, 在类作用域内,不用指出T
++*this;//前进一个值
return ret;//返回保存的状态
}
类模板和友元:
当一个类包含一个友元声明时,类与友元各自是否是模板是无关的。如果一个类模板包含一个非模板友元,则友元被授权可以访问所有模板实例。若友元自身是模板,类可以授权给所有友元模板实例,也可以只授权给特定实例。
一对一友好关系:
类模板与另一个(类或函数)模板间友好关系的最常见形式是建立对应实例及其友元间的友好关系。例如,Blob类应该将BlobPtr类和一个模板版本的Blob相等运算符定义为友元。
为了引用(类或函数)模板的一个特定实例,我们必须首先声明模板自身。
//前置声明,在Blob中声明友元所需要的
template<typename> class BlobPtr;
template<typename> class Blob;//运算符==中的参数所需要的
template<typename T>
bool operator==(const Blob<T>&, const Blob<T>&);
template<typename T>
class Blob
{
//每个Blob实例将访问权限授予用相同类型实例化的BlobPtr和相等运算符
friend class BlobPtr<T>;
friend bool operator==<T>(const Blob<T>&, const Blob<T>&);
//友元的声明用Blob的模板形参作为他们自己的模板实参
//因此,友好关系被限定在用相同类型实例化的Blob与BlobPtr相等运算符之间
};
Blob<char> ca;//BlobPtr<char>和operator==<char>都是本对象的友元
Blob<int> ia;//BlobPtr<int>和operator==<int>都是本对象的友元
通用和特定的模板友好关系:
一个类也可以将另一个模板的每个实例都声明为自己的友元,或者限定特定的实例为友元。
//前置声明,在将模板的一个特定实例声明为友元时要用到
template<typename T> class Pal;
class C//普通的非模板类
{
friend class Pal<C>;//用类C实例化的Pal是C的一个友元
//Pal2的所有实例都是C的友元;这种情况无需前置声明
template<typename T> friend class Pal2;
};
template<typename T>
class C2//C2本身是一个类模板
{
//C2的每个实例将相同实例化的Pal声明为友元
friend class Pal<T>;//Pal的模板声明必须在作用域内
//Pal2的所有实例都是C2的每个实例的友元,不需前置声明
//为了让所有实例称为友元,友元声明中必须使用与类模板本身不同的模板参数
template<typename X> friend class Pal2;
//Pal3是一个非模板类,他是C2所有实例的友元,无需前置声明
friend class Pal3;
};
令模板自己的类型参数成为友元:
在新标准中,可以将模板类型参数声明为友元
template<typename Type>//Type是内置类型或非内置类型都可
class Bar
{
friend Type;//将访问权限授予用来实例化Bar的类型
};
模板类型别名:
/**
* 类模板的一个实例定义了一个类类型,与其他任何类型一样,
* 可以定义一个typedef来引用实例化的类
*/
typedef Blob<string> StrBlob;
/**
* 由于模板不是一个类型,不能定义一个typedef引用一个模板
* 即,不能定义typedef引用一个Blob<T>
*
* 但C++11标准允许我们为类模板定义一个类型别名
*/
template<typename T> using twin=pair<T, T>;
twin<string> authors;//authors是一个pair<string, string>
twin<int> ti;//ti是一个pair<int, int>
//当定义一个模板类型别名时,可固定一个或多个模板参数
template<typename T> using partNo=pair<T, unsigned>;
partNo<int> pi;//pi是一个pair<int, unsigned>
类模板的static成员:
与其他任何类相同,类模板可以声明static
/**
* 每个Foo实例都有自己的static成员实例
* 即,对任意类型X, 都有一个Foo<X>::ctr和
* 一个Foo<X>::count成员。所有Foo<X>类型
* 的对象共享相同的ctr对象和count函数
* @tparam T
*/
template<typename T>
class Foo
{
public:
static size_t cout()
{
return ctr;
}
private:
static size_t ctr;
};
模板类的static成员必须有且仅有一个定义。但是,类模板的每个实例都有一个独有的static对象,因此我们将static成员也定义成模板。
/**
* 每个Foo实例都有自己的static成员实例
* 即,对任意类型X, 都有一个Foo<X>::ctr和
* 一个Foo<X>::count成员。所有Foo<X>类型
* 的对象共享相同的ctr对象和count函数
* @tparam T
*/
template<typename T>
class Foo
{
public:
//只有在被使用时,才会实例化
static size_t cout()
{
return ctr;
}
private:
static size_t ctr;
};
//定义并初始化:模板参数列表,类型和名字
template<typename T>
size_t Foo<T>::ctr=0;
int main()
{
Foo<int> fi;//实例化Foo<int>和static数据成员ctr
auto ct=Foo<int>::cout();//实例化Foo<int>::count()
ct=fi.cout();//使用Foo<int>::count
ct=Foo::count();//✖,使用哪个模板实例的count?
}
③模板参数
类似函数参数的名字,模板参数的名字没有任何含义。通常将类型参数名字命名为T, 但实际上任何名字都可以。
模板参数与作用域:
模板参数遵循普通的作用域规则。一个模板参数名的可用范围是在其声明之后,至模板声明或定义结束之前。模板参数会隐藏外层作用域中声明的相同名字。但是与其他大多数上下文不同,在模板内不能重用模板参数名。
由于参数名不能重用,所以一个模板参数名在一个特定的模板参数列表只能出现一次
typedef double A;
template<typename A, typename B>
void f(A a, B b)
{
A tmp=a;//tmp的类型为A, 而不是double, typedef A被隐藏了
double B;//✖,B也是类型名,重声明模板参数B
}
模板声明:
模板声明必须包含模板参数。与函数参数相同,声明中的模板参数的名字不必与定义中的相同,但一个给定的模板每个声明和定义必须有相同数量和种类的参数。
使用类类型的成员:
默认模板实参:
C++11中,我们可以为函数和类模板提供默认实参。
与函数默认实参一样,对于一个模板参数,只有当他右侧所有参数都有默认实参时,它才可以有默认实参。
//默认模板实参less<T>, 默认函数实参F()
//F是可调用对象, f是函数参数
//当用户使用这个函数是可提供自定义比较操作,但不是必须的
template<typename T, typename F=less<T>>
int compare(const T &v1, const T &v2, F f=F())
{
if(f(v1, v2)) return -1;
if(f(v2, v2)) return 1;
return 0;
}
模板默认实参与类模板:
使用类模板时,要在其后跟上<>。若其所有模板参数都有默认实参,且希望使用,则空<>
template<typename T=int>//T默认为int
class Numbers
{
};
Numbers<long long> ll;
Numbers<> Int;
④成员模板
一个类(无论是普通类,还是类模板)可以包含本身是模板的成员函数。这种成员被称为成员模板。成员模板不能是虚函数。
普通(非模板)类的成员函数:
//代替默认delete去自定义删除
class DebugDelete
{
public:
DebugDelete(ostream &s=cerr):os(s) {}
template<typename T>
void operator() (T *p) const
{
os<<"das"<<endl;
delete p;
}
private:
ostream &os;
};
int main()
{
double *p=new double;
DebugDelete d;//可以像delete表达式一样使用对象
d(p);//调用DebugDelete::operator() (double *p)释放p
int *ip=new int;
DebugDelete() (ip);//在一个临时对象上删除ip, DebugDelete::operator() (int *p)
/**
* 也可以将DebugDelete用作unique_ptr的删除器
* 为了重载unique_ptr的删除器,在<>内给出删除器类型,并提供一个这种类型对象给unique_ptr的
* 构造函数
*/
//销毁p指向的对象,实例化 DebugDelete::operator()<int> (int *p)
unique_ptr<int, DebugDelete> p2(new int, DebugDelete());
}
类模板的成员模板:
对于类模板,也可以为其定义成员模板。在此情况下,类和成员各自有自己的、独立的模板参数
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) {}
实例化与成员模板:
为了实例化一个类模板的成员模板,必须同时提供类和函数模板的实参。在哪个对象上调用成员模板,编译器就根据该对象的类型来推断类模板参数的实参。根据传递给成员模板的函数实参来推断它的模板实参。
⑤控制实例化
当模板被使用时才会实例化,意味着相同的实例可能出现在多个对象文件中。当两个或多个独立编译的源文件使用了相同的模板,并提供了相同的模板参数时,每个文件都会有该模板的一个实例。
在大型系统中,在多个文件实例化相同的模板会带来额外开销。C++11中,可通过显式实例化来避免这种开销。一个显式实例化有如下形式:
//模板定义
template<typename T>
class Blob;
/**
* declaration是一个类或函数声明,其中所有模板参数已被替换为模板实参
*/
extern template declaration;//实例化声明
template declaration;//实例化定义
//Application.cc
/**
* 当编译器遇到extern模板声明时,不会在本文件中生成实例化代码
* 将一个实例化声明为extern表示承诺在程序其他位置有该实例化的
* 一个非extern声明(就是定义)
*/
extern template class Blob<string>;//声明
//由于编译器在使用一个模板时自动对其实例化,因此extern声明必须出现在任何
//使用此实例化版本的代码之前
Blob<string> sa1, sa2;//实例化定义出现在其他位置
//templateBuild.cc
//实例化文件必须提供定义,为在别的文件的extern声明的东西
/**
* 当编译器遇到一个实例化定义时
* 为其生成代码。
* 当编译程序时,必须将Application.o和templateBuild.o
* 链接在一起
*/
template class Blob<string>;//实例化定义
实例化定义会实例化所有成员:
⑥效率与灵活性
2. 模板实参推断
从函数实参来确定模板实参的过程被称为模板实参推断。
①类型转换与模板类型参数
与非模板函数一样,在一次调用中传递给函数模板的实参被用来初始化函数的形参。若一个函数形参的类型使用了模板类型参数,它将采用特殊的初始化规则。只有很有限的几种类型转换会自动的应用于这些实参。编译器通常不是对实参进行类型转换,而是生成一个新的模板实例。
与往常一样,顶层const无论在形参还是实参中都会被忽略。在其他类型转换中,能在调用中应用于函数模板的包括下面两项:
[1] const转换:将一个非const对象的引用(指针),传递给一个const引用(指针)。
[2] 数组或函数指针转换:如果函数形参不是引用类型,则可以对数组或函数类型的实参应用正常的指针转换。数组实参->指向首元素的指针。函数实参->该函数类型的指针
其他转换,如算术转换、派生类向基类的转换以及用户定义的转换,都不能应用于函数模板。
使用相同模板参数类型的函数形参:
一个模板类型参数可以用作多个函数形参的类型。由于只允许几种有限的类型转换,因此传递给这些实参的形参,必须具有相同的类型。如果推断出类型不同,就是错误的。比如compare函数接受两个const T&参数,其中实参必须是相同类型。
若想实参类型不同,则提供不同的形参类型即可,const T1&, const T2&。
正常类型转换应用于普通函数实参:
函数模板可以有用普通类型定义的参数。这种函数实参不进行特殊处理;他们正常转化为对应形参的类型。
template<typename T>
ostream &print(ostream &os, const T &obj)
{
return os<<obj;
}
int main()
{
//由于os类型是固定的,所以传递给它的实参会正常的类型转换
print(cout, 42);//实例化print(ostream&, int)
ofstream f("output");
print(f, 10);//将f隐式转换为ostream
}
②函数模板显式实参
在某些情况下,编译器无法推断出模板实参的类型。其他一些情况下,希望允许用户控制模板实例化。当函数返回类型与参数列表中任何类型都不相同时,这两种情况最常出现。
指定显式模板实参:
//编译器无法推断出T1, 它未出现在函数参数列表中,只有运行结束时T1才知道,但是实例化需要编译阶段就知道
//所以,每次调用时,必须为T1提供显式模板实参
//方式同类,<>位于函数名之后,实参列表之前
template<typename T1, typename T2, typename T3>
T1 sum(T2, T3);
int i;
long long lng;//T1是显式指定的,T2和T3是推断出来的
auto val3=sum<long long>(i, lng);//long long sum(int, long long)
显式模板实参按照从左到右的顺序与对应的模板参数匹配。只有尾部的参数的显式模板实参可以省略,前提是可以推断出来。
T3 sum(T1, T2):需要指定三个。
正常类型转换应用于显式指定的实参:
对于普通类型定义的函数参数,允许进行正常的类型转换,出于同样的原因,对于模板类型参数已经显式指定了的函数实参,也进行正常的类型转换:
//compare(const T&, const T&)
long lng;
compare(lng, 2024);//✖,模板参数不匹配,必须有相同类型
compare<long>(lng, 2024);//✔,实例化compare(long, long), 2024转化为long
compare<int>(lng, 2024);//✔,实例化compare(int, int), lng转为int
③尾置返回类型与类型转换
当我们希望用户确定返回类型时,用显式模板实参表示模板函数的返回类型是很有效的。
template<typename It>
??? &fcn(It beg, It end)
{
return *beg;//返回序列中第一个元素的引用
}
//我们并不知道返回结果的准确类型,但知道所需类型所处理的序列的元素类型
vector<int> vi;
auto &i=fcn(vi.begin(), vi.end());//fcn应该返回int&
/**
* 此例中,函数应该返回*beg, 而且我们可以用decltype(*beg)来获取
* 此表达式的类型。
* 但是,在编译器遇到函数的参数列表之前,beg都是不存在的。
* 为了定义此函数,我们必须使用尾置返回类型。
* 由于尾置返回类型出现在参数列表之后,他可以使用函数的参数
*/
//尾置允许我们在参数列表之后声明返回类型
template<typename It>
auto fcn(It beg, It end) -> decltype(*beg)
{
return *beg;
}
进行类型转换的标准库模板类:
有时无法直接获得所需要的类型。例如我们希望编写一个类似上面fcn的函数,但是返回一个元素的值而非引用。
但是有一个问题:对于传递的参数的类型,几乎一无所知。在此函数,我们知道唯一可使用的操作是迭代器操作,而所有迭代器操作不会生成元素,只能生成元素的引用。
为了获得元素类型,可以使用标准库的类型转换模板。这些模板定义在头文件type_traits。这个头文件中的类通常用于所谓的模板元程序设计,这一主题已超出本书范围。但是,类型转换模板在普通编程中也很有用
在本例中,可以使用remove_reference来获得元素类型。remove_reference模板有一个模板类型参数和一个名为type的(public)类型成员。如果用一个引用类型实例化remove_reference,则type表示被引用的类型。
例如,如果我们实例化remove_reference<int&>,则type成员是int;若实例化remove_reference<string&>则type类型是string。
更一般的,给定一个迭代器beg
remove_reference<decltype(*beg)>::type;
//上述表达式将获得beg引用的元素类型:decltype(*beg)
//返回元素类型的引用类型,remove_reference::type脱去引用,剩下元素类型本身
//组合使用remove_reference、尾置返回以及decltype, 就可以在函数中返回元素拷贝的值
template<typename It>//type是一个类的成员,而该类依赖于一个模板参数。因此,我们必须在返回类型的声明中使用
auto fcn2(It beg, It end) -> typename remove_reference<decltype(*beg)>::type//typename来告诉编译器,type表示一个类型
{
return *beg;
}
④函数指针和实参推断
当用一个函数模板初始化一个函数指针或为一个函数指针赋值时,编译器使用指针的类型来推断模板实参。
template<typename T>
int compare(const T&, const T&);
//pf1指向实例int compare(const int&, const int&)
//pf1中的参数类型决定了T的模板实参类型(int)
int (*pf1)(const int &, const int&)=compare;
//若不能从函数指针类型确定模板实参,则产生错误
//func重载版本,每个版本接受一个不同的函数指针类型
void func(int(*)(const string&, const string&));
void func(int(*)(const int&, const int&));
func(compare);//✖,使用哪个compare实例?是int还是string
func(compare<int>);//✔,显示指出实例化哪个版本compare
⑤模板实参推断和引用
为了理解如何从函数调用进行类型推断,考虑下面例子
/**
* 函数参数p是一个模板类型参数T的引用,
* 非常重要的记住两点:编译器会应用正常的引用绑定规则;
* const是底层的,不是顶层的
*/
template<typename T>
void f(T &p);
从左值引用函数参数推断类型:
从右值引用函数参数推断类型:
引用折叠和右值引用参数:
编写接受右值引用参数的模板函数:
模板参数可以推断为一个引用类型,会对代码有很大影响:
/**
* 当我们对一个右值调用f3时,例如42,T为int
* 此时t的类型为int,且通过拷贝val的值初始化
* 当对t赋值时,val保持不变
*
*
* 当对一个左值i调用f3时,则T为int&。当定义并初始化局部变量
* t时,赋予它类型int&。对t的初始化将其绑定到val上。当对t赋值
* 时,也同时改变了val的值。此时if永远为true
*/
template<typename T>
void f3(T&& val)
{
T t=val;//拷贝还是绑定一个引用?
t=fcn(t);//赋值是只改变T, 还是既改变T也改变val
if(val==t) {}//若T为引用类型,一直为true
}
当代码中涉及的类型可能是普通(非引用)类型,也可能是引用类型,编写正确的代码就很困难。
在实际中,右值引用通常用于两种情况:模板转发其实参或模板重载。
//使用右值引用的函数模板,通常使用下面的方式重载
//同非模板函数
template<typename T>
void f(T&&);//绑定到非const右值
template<typename T>
void f(const T&);//左值和const右值
⑥理解std::move
标准库函数是使用右值引用的模板的一个很好的例子。
虽然不能直接将一个右值引用绑定到一个左值上,但可以用move获得一个绑定到左值上的右值引用。
std:move是如何定义的:
/**
* 函数参数是一个指向模板类型参数的右值引用,通过引用折叠,此参数可以和任何类型的实参匹配
* 既可以传递给move一个左值,也可以传递给它一个右值
*
*/
template<typename T>
typename remove_reference<T>::type&& move(T&& t)
{
return static_cast<typename remove_reference<T>::type&&>(t);
}
string s1("hi!"), s2;
s2=std::move(string("bye"));//✔,从一个右值移动数据
s2=std::move(s1);//✔,但在赋值后,s1的值是不确定的(资源被移动到s2了)
std::move是如何工作的:
从一个左值static_cast到一个右值引用是允许的:
⑦转发
某些函数需要将其一个或多个实参连同类型不变的转发给其他函数。在此情况下,需要保持被转发实参的所有性质,包括实参类型是否是const的以及实参是左值还是右值。
//接受一个可调用对象和另外两个参数的模板
//函数调用可调用对象,并将两个参数逆序传递给他
//flip1是一个不完整的实现:顶层const和引用丢失了
template<typename F, typename T1, typename T2>
void flip1(F f, T1 t1, T2 t2)
{
f(t2, t1);
}
//这个代码一般情况下工作很好,但当我们用它调用一个接受引用参数的
//函数时就会出现问题
void f(int v1, int &v2)//注意v2是一个引用
{
cout<<v1<<" "<<++v2<<endl;
}
//上面代码中f改变了绑定到v2实参的值,但我们如果用
//flip1调用f,f所做的改变就不会影响实参
f(42, i);//f改变了实参i
flip1(f, j, 42);//通过flip1调用f不会改变j
/**
* 问题在于j被传递给flip1的参数t1.此参数是一个普通的、非引用的类型int,而非int&
* 因此,这个flip1调用会实例化为
* void flip1(void(*fcn)(int, int&), int t1, int t2)
*
* j的值被拷贝到t1中,f中的引用参数被绑定到t1, 而非j, 从而其改变不会影响j
*/
定义能保持类型信息的函数参数:
在调用中使用std::forward保持类型信息:
3. 重载与模板
函数模板可以被另一个模板或者普通非模板函数重载。与往常一样,名字相同的函数必须具有不同数量或类型的参数
如果涉及函数模板,则函数匹配规则会在以下几个方面受到影响
编写重载模板:
//打印任何无法处理的类型
template<typename T>
string debug_rep(const T &t)//接受一个const对象的引用
{
ostringstream ret;
ret<<t;//使用T的输出运算符打印t的一个表示形式
return ret.str();//返回ret绑定的string的一个副本
}
//打印指针的值,后跟指针指向的对象
//此函数不能应用于char*,因为IO库为char*值定义了一个<<版本,此<<版本
//假定指针表示一个空字符结尾的字符数组,并打印数组的内容而非地址的值
template<typename T>
string debug_rep(T *p)
{
ostringstream ret;
ret<<p;//打印指针本身的值
if(p) ret<<debug_rep(*p);//打印p指向的值
return ret.str();//返回ret绑定的一个string副本
}
int main()
{
string s("hi");
cout<<debug_rep(s)<<endl;//实例化第一个,第二个版本要求一个指针类型参数的函数模板,而s不是指针
//指针调用debug_rep
cout<<debug_rep(&s)<<endl;
/**
* 两个函数都生成可行的实例
* debug_rep(const string*&), 第一个版本实例化而来,T被绑定到string*
* debug_rep(string*), 由第二个版本是厉害吧而来,T被绑定到string
*
* 第二个版本的debug_rep的实例是此调用的精确匹配。
* 第一个版本要进行普通指针到const指针的转换
*/
}
多个可行模板:
非模板和模板重载:
//打印任何无法处理的类型
template<typename T>
string debug_rep(const T &t)//接受一个const对象的引用
{
ostringstream ret;
ret<<t;//使用T的输出运算符打印t的一个表示形式
return ret.str();//返回ret绑定的string的一个副本
}
//打印指针的值,后跟指针指向的对象
//此函数不能应用于char*,因为IO库为char*值定义了一个<<版本,此<<版本
//假定指针表示一个空字符结尾的字符数组,并打印数组的内容而非地址的值
template<typename T>
string debug_rep(T *p)
{
ostringstream ret;
ret<<p;//打印指针本身的值
if(p) ret<<debug_rep(*p);//打印p指向的值
return ret.str();//返回ret绑定的一个string副本
}
string debug_rep(const string &s)//普通版本的debug_rep
{
return s;
}
int main()
{
string s("hi");
debug_rep(s);
/**
* 有两个同样好的可行函数
* debug_rep<string>(const string&),第一个模板,T被绑定到string
* debug_rep(const string&),普通非模板函数,第三个
*
* 两者相同,但编译器会选择非模板,更特例化的一个
*/
}
重载模板和类型转换:
缺少声明,可能导致程序行为异常:
4. 可变参数模板
一个可变参数模板就是一个接受可变数目参数的模板函数或模板类。可变数目的参数被称为参数包。
模板参数包:表示0个或多个模板参数。
函数参数包:表示0个或多个函数参数。
用一个省略号来指出一个模板参数或函数参数表示一个包。在一个模板参数列表中,class...或typename...指出接下来的参数表示0个或多个类型的列表;一个类型名后面跟一个省略号表示0个或多个给定类型的非类型参数的列表。在函数参数列表中,如果一个参数的类型是一个模板参数包,则此参数也是一个函数参数包。
sizeof...运算符:
当需要知道包中有多少元素时,可以使用sizeof...运算符。类似sizeof,sizeof...也返回一个常量表达式,而且不会对其实参求值。
template<typename... Args>
void g(Args... args)
{
cout<< sizeof...(Args)<<endl;//类型参数的数目
cout<< sizeof...(args)<<endl;//函数参数的数目
}
①编写可变参数函数模板
②包扩展
理解包扩展:
③转发参数包
5. 模板特例化
定义函数模板特例化:
函数重载与模板特例化:
类模板特例化:
类模板部分特例化:
特例化的是成员而不是类:
十六. 标准库特殊设施
1. tuple类型
tuple是类似pair的模板。每个pair成员的类型都不相同,但每个pair恰好有两个成员。不同tuple类型的成员也不相同,但一个tuple可以有任意数量的成员。每个确定的tuple类型的成员数目是固定的,但一个tuple类型的成员数目可以和另一个tuple不同。
①定义和初始化tuple
tuple<string, vector<double>, int, list<int>>
someVal("const", {3.14, 1.5926}, 0, {0, 1, 2, 3, 4});
tuple<size_t, size_t, size_t> threeD={1, 2, 3};//✖,tuple要求直接列表初始化,而不是赋值
tuple<size_t, size_t, size_t> threeD{1, 2, 3};//✔
访问tuple的成员:
关系和相等运算符:
②使用tuple返回多个值
tuple的一个常见用途是从一个函数返回多个值。
返回tuple的函数:
class Sales_data
{
};
typedef tuple<vector<Sales_data>::size_type, vector<Sales_data>::const_iterator, vector<Sales_data>::const_iterator>
matches;
vector<matches> findBook();
2. bitset类型
标准库定义了bitset类,使得位运算更为容易,并且能够处理超过最长整型类型大小的位集合。该类定义在bitset头文件中。
①定义和初始化bitset
用unsigned值初始化bitset:
从一个string初始化bitset:
②bitset操作
提取bitset的值:
bitset的IO运算符:
使用bitset:
3. 正则表达式
①使用正则表达式库
指定regex对象的选项:
指定或使用正则表达式时的错误:
正则表达式类和输入序列类型:
②匹配与Regex迭代器
使用sregex_iterator:
使用匹配数据:
③使用子表达式
子表达式用于数据验证:
使用子匹配操作:
④使用regex_replace
只替换输入序列一部分:
用来控制匹配和格式的标志:
使用格式标志:
4. 随机数
程序通常需要一个随机数源。C++11前,C和C++都依赖于一个简单的C库函数rand来生成随机数。此函数生成均匀分布的伪随机整数,每个随机数范围在0和一个系统相关的最大值(至少为32767)之间。
rand函数有一些问题:很多程序需要不同范围的随机数。一些应用需要随机浮点数,一些程序需要非均匀分布的数。
定义在头文件random中的随机数库通过一组协作的类来解决这些问题:随机数引擎类和随机数分布类。
①随机数引擎和分布
标准库定义了多个随机数引擎类,区别在于性能和随机性质量不同。每个编译器都会指定一个作为default_random_engine类型。此类型一般具有最常用
default_random_engine e;//生成随机无符号数
for(size_t i=0;i<10;++i)
cout<<e()<<endl;//生成下一个随机数
分布类型和引擎:
为了得到在一个指定范围内的数,我们使用一个分布类型对象。
//uniform_int_distribution<unsigned>生成均匀分别的unsigned值,(0, 9)是随机范围
uniform_int_distribution<unsigned> u(0, 9);
default_random_engine e;
cout<<u(e);//将u作为随机数源
比较随机数引擎和rand函数:
引擎生成一个数值序列:
vector<unsigned> bad_randVec()
{
default_random_engine e;
uniform_int_distribution<unsigned> u(0, 9);
vector<unsigned> ret;
for(int i=0;i<10;++i)
ret.push_back(u(e));
return ret;
}
vector<unsigned> v1=bad_randVec();//v1==v2
vector<unsigned> v2=bad_randVec();
//正确方式是将引擎和关联的分别对象定义为static的
vector<unsigned> good_randVec()
{
//由于希望引擎和分布对象保持状态,因此应该将他们定义为static的,从而每次调用都生成新的数
static default_random_engine e;
static uniform_int_distribution<unsigned> u(0, 9);
vector<unsigned> ret;
for(int i=0;i<10;++i)
ret.push_back(u(e));
return ret;
}
设置随机数发生器种子:
default_random_engine e1;//使用默认种子
default_random_engine e2(2145);//使用给定的种子值
//e3和e4将生成相同的随机数序列,因为其种子值相同
default_random_engine e3;//使用默认种子
e3.seed(32767);//设置新种子值
default_random_engine e4(32767);
②其他随机数分布
生成随机实数:
default_random_engine e;//生成无符号随机整数
uniform_real_distribution<double> u(0, 1);//0到1(包含)的均匀分布
cout<<u(e);
使用分布的默认结果类型:
生成非均匀分布的随机数:
bernoulli_distribution类:
string resp;
default_random_engine e;//e应保持状态,在循环外定义
bernoulli_distribution b;//默认是50/50机会
bool first=b(e);
bernoulli_distribution b2(.55);//给一个微小优势,55/45机会先行
5. IO库再探
第七章介绍了IO库的基本结构以及最常用部分,本节将介绍三个更特殊的IO库特性:格式控制、未格式化IO和随机访问。
①格式化输入输出
很多操纵符改变格式状态:
控制布尔值的格式:
指定整型的进制:
在输出中指出进制:
控制浮点数格式:
指定打印精度:
指定浮点数记数法:
打印小数点:
输出补白:
控制输入格式:
②未格式化的输入/输出操作
单字节操作:
将字符放回输入流:
从输入操作返回的int值:
多字节操作:
确定读取了多少个字符:
③流随机访问
seek和tell函数:
只有一个标记:
重定位标记:
访问标记:
读写同一个文件:
十七. 用于大型工具的程序
1. 异常处理
①抛出异常
栈展开:
栈展开过程中对象被自动销毁:
析构函数和异常:
异常对象:
②捕获异常
查找匹配的处理代码:
重新抛出:
捕获所有异常的处理代码:
③函数try语句块与构造函数
④noexcept异常说明
违反异常说明:
异常说明的实参:
noexcept运算符:
异常说明与指针、虚函数和拷贝控制:
⑤异常类层次
书店应用程序的异常类:
使用我们自己的异常类型:
2. 命名空间
①命名空间定义
每个命名空间都是一个作用域:
命名空间可以是不连续的:
定义本书的命名空间:
定义命名空间成员:
模板特例化:
全局命名空间:
嵌套命名空间:
内联命名空间:
未命名的命名空间:
②使用命名空间成员
命名空间的别名:
using声明:扼要概述:
using指示:
using指示与作用域:
using指示例子:
头文件与using声明或指示:
③类、命名空间与作用域
实参相关的查找与类类型形参:
查找std::move和std::forward:
友元声明与实参相关的查找:
④重载与命名空间
与实参相关的查找与重载:
重载与using声明:
重载与using指示:
跨越多个using指示的重载:
3. 多重继承与虚继承
①多重继承
多重继承的派生类从每个基类中继承状态:
派生类构造函数初始化所有基类:
继承的构造函数与多重继承:
析构函数与多重继承:
多重继承的派生类的拷贝与移动操作:
②类型转换与多个基类
基于指针类型或引用类型的查找:
③多重继承下的类作用域
④虚继承
另一个Panda类:
使用虚基类:
支持向基类的常规类型转换:
虚基类的成员可见性:
⑤构造函数与虚继承
虚继承对象的构造方式:
构造函数与析构函数的次序:
十八. 特殊工具与技术
1. 控制内存分配
①重载new和delete
operator new接口和operator delete接口:
malloc函数与free函数:
②定位new表达式
显式的析构函数调用:
2. 运行时类型识别
①dynamic_cast运算符
指针类型的dynamic_cast:
引用类型的dynamic_cast:
②typeid运算符
使用typeid:
③使用RTTI
类的层次关系:
类型敏感的相等运算符:
虚equal函数:
基类equal函数:
④type_info类
3. 枚举类型
枚举成员:
和类一样,枚举也定义新的类型:
指定enum大小:
枚举类型的前置声明:
形参匹配与枚举类型:
4. 类成员指针
①数据成员指针
使用数据成员指针:
返回数据成员指针的函数:
②成员函数指针
使用成员函数指针:
使用成员指针的类型别名:
成员指针函数列表:
③将成员函数用作可调用对象
使用function生成一个可调用对象:
使用mem_fn生成一个可调用对象:
使用bind生成一个可调用对象:
5. 嵌套类
声明一个嵌套类:
在外层类之外定义一个嵌套类:
定义嵌套类的成员:
嵌套类的静态成员定义:
嵌套类作用域中的名字查找:
嵌套类和外层类是相互独立的:
6. union:一种节省空间的类
定义union:
使用union:
匿名union:
含有类类型成员的union:
使用类管理union成员:
管理判别式并销毁string:
管理需要拷贝控制的联合成员:
7. 局部类
局部类不能使用函数作用域中的变量:
常规的访问保护规则对局部类同样适用:
局部类中的名字查找:
嵌套的局部类:
8. 固有的不可移植特性
①位域
使用位域:
②volatile限定符
合成的拷贝对volatile无效:
③链接指示:extern“C”
声明一个非C++的函数:
链接指示与头文件:
指向extern"C"函数的指针:
链接指示对整个声明都有效:
导出C++函数到其他语言:
重载函数与链接指示: