某些应用程序对内存分配有特殊的需求,需要自定义内存分配的细节,比如使用
new
将对象放置在特定的内存空间中。因此,需要重载new
和delete
运算符以控制内存分配的过程。
当使用
new
表达式时:
string *sp = new string("a value"); // 分配并初始化一个string对象
string *arr = new string[10]; // 分配10个默认初始化的string对象
实际执行了三步操作:
- 调用名为
operator new
(或者operator new[]
)的标准库函数,分配一块足够大的、原始的、未命名的内存空间以便存储特定类型的对象(或者对象数组)。- 运行相应的构造函数以构造这些对象,并传入初始值。
- 返回指向该对象的指针。
当使用
delete
表达式时:
delete sp; // 销毁*sp,然后释放sp指向的内存空间。
delete[] arr; // 销毁数组中的元素,然后释放对应的内存空间。
实际执行了两步操作:
- 对所指对象或数组中的元素执行对应的析构函数。
- 调用名为
operator delete
(或者operator delete[]
)的标准库函数释放内存空间。
因此,如果应用程序希望控制内存分配的过程,则需要定义自己的
operator new
和operator delete
函数。编译器会优先使用自定义的版本。
当编译器发现一条
new
表达式或delete
表达式后,将在程序中查找可供调用的operator
函数。如果被分配(释放)的对象是类类型,则编译器首先在类及其基类的作用域中查找。此时如果该类含有相应的成员,则进行调用。否则,编译器在全局作用域中查找。此时,如果编译器找到了自定义的版本,则进行调用;如果没找到,则使用标准库定义的版本。
标准库定义了
operator new
和operator delete
函数的8个重载版本。当将这些运算符函数定义成类的成员时,它们是隐式静态的,而且不能操纵类的任何数据成员,因为operator new
用在对象构造之前而operator delete
用在对象销毁之后,
// 这些版本可能抛出异常。与析构函数类似,operator delete也不允许抛出异常。
void *operator new(size_t); // 分配一个对象
void *operator new[](size_t); // 分配一个数组
void operator delete(void *) noexcept; // 释放一个对象
void operator delete[](void *) noexcept; // 释放一个数组
// 这些版本承诺不会抛出异常,通过定义在new头文件中的nothrow对象请求非抛出版本。
void *operator new(size_t, nothrow_t &) noexcept;
void *operator new[](size_t, nothrow_t &) noexcept;
void operator delete(void *, nothrow_t &) noexcept;
void operator delete[](void *, nothrow_t &) noexcept;
对于
operator new
或者operator new[]
函数来说,返回类型必须是void*
,第一个形参类型必须是size_t
且不能含有默认实参,当给对象或者数组分配空间时,传入所需的字节数。
如果想要自定义operator new
函数,则可以提供额外的形参。此时,必须使用new
的定位形式进行传递。
需要注意的是,下面这个函数无论如何不能被用户重载:
// 只供标准库使用
void *operator new(size_t, void*);
对于
delete new
或者delete new[]
函数来说,返回类型必须是void
,第一个形参的类型必须是void*
,指向待释放的内存。
如果定义成类的成员时,该函数可以包含类型为size_t
的形参,初始值是对象的字节数。size_t
形参可用于删除继承体系中的对象。如果基类有一个虚析构函数,则传递给operator delete
的字节数将因待删除指针所指对象的动态类型不同而有所区别。而且,实际运行的operator delete
函数版本也由对象的动态类型决定。
需要注意的是,提供新的
operator new
和operator delete
函数的目的在于改变内存分配的方式,但是不管怎样,都不能改变new
和delete
运算符的基本含义。
// 编写operator new和operator delete的一种简单方式,其他版本与之类似。
void *operator new(size_t size) {
if (void *mem = malloc(size)) {
return mem;
} else {
throw bad_alloc();
}
}
void operator delete(void *mem) noexcept {
free(mem);
}
尽管
operator new
和operator delete
函数一般用于new
表达式,然而它们毕竟是标准库的两个普通函数,因此普通的代码也可以直接调用它们。
在c++的早期版本中,应用程序如果想把内存分配与初始化分离开来的话,需要调用operator new
和operator delete
。它们负责分配或释放内存空间,但是不会构造或销毁对象。此时,应该使用new
的**定位new
**形式构造对象,从而为分配函数提供额外的信息:
// place_address必须是一个指针。
// initializers提供一个(可能为空的)以逗号分隔的初始值列表,将用于构造新分配的对象。
new(place_address) type
new(place_address) type(initializers)
new(place_address) type[size]
new(place_address) type[size] { braced initializer list }
当仅通过一个地址值调用时,定位
new
使用operator new(size_t, void*)
。这是一个无法自定义的版本。该函数不分配任何内存,只是简单地返回指针实参;然后由new
表达式负责在指定的地址初始化对象以完成整个工作。事实上,定位new
允许在一个特定的、预先分配的内存地址上构造对象。
定位
new
与allocator
的contruct
成员一个重要的区别是:传给construct
的指针必须指向同一个allocator
对象分配的空间,但是传给定位new
的指针无须指向operator new
分配的内存。实际上,传给定位new
表达式的指针甚至不需要指向动态内存。
既可以通过对象调用析构函数,也可以通过对象的指针或引用调用:
string *sp = new string("a value");
sp->~string();
调用析构函数会销毁对象,但是不会释放内存。如果需要的话,可以重新使用该空间。
运行时类型识别的功能由两个运算符实现:
typeid
:用于返回表达式的类型。dynamic_cast
:用于将基类的指针或引用安全地转换成派生类的指针或引用。
当这两个运算符用于某种类型的指针或引用,并且该类型含有虚函数时,运算符将使用指针或引用所绑定对象的动态类型。
这两个运算符特别适用于以下情况:想使用基类对象的指针或引用执行某个派生类操作并且该操作不是虚函数。一般来说,只要有可能应该尽量使用虚函数。
然而,并非任何时候都能定义一个虚函数。假设无法使用虚函数,则可以使用一个RTTI运算符。另一方面,与虚成员函数相比,使用RTTI运算符蕴含着更多潜在的风险:程序员必须清楚地知道转换的目标类型并且必须检查类型转换是否被成功执行。
dynamic_cast
运算符的使用形式:
// type必须是一个类类型,并且通常情况下该类型应该含有虚函数。
// e必须是一个有效的指针
dynamic_cast<type*>(e)
// e必须是一个左值
dynamic_cast<type&>(e)
// e不能是左值
dynamic_cast<type&&>(e)
e
的类型必须符合以下条件中的任意一个:
e
的类型是目标type
的公有派生类。e
的类型是目标type
的公有基类。e
的类型就是目标type
的类型。如果符合,则类型转换可以成功。否则,转换失败。如果转换目标是指针类型并且失败了,则结果为0。如果转换目标是引用类型并且失败了,则抛出一个
bad_cast
异常。
// 假定Base类至少含有一个虚函数,Derived是Base的公有派生类。
// 如果有一个指向Base的指针bp,则可以在运行时将它转换成指向Derived的指针。
// 在条件部分定义了dp,好处是可以在一个操作中同时完成类型转换和条件检查两项任务。
// 而且,dp在if外部是不可访问的。一旦转换失败,即使后续的代码忘了做相应判断,也不会
// 接触到这个未绑定的指针,从而确保程序是安全的。
if (Derived *dp = dynamic_cast<Derived*>(bp)) {
// 使用dp指向的Derived对象
} else {
// 使用bp指向的Base对象
}
可以对一个空指针执行
dynamic_cast
,结果是所需类型的空指针。
因为不存在所谓的空引用,所以对于引用类型来说无法使用与指针类型完全相同的错误报告策略。
void f(const Base &b) {
try {
const Derived &d = dynamic_cast<const Derived&>(b);
// 使用b引用的Derived对象
} catch(bad_cast) {
// 处理类型转换失败的情况
}
}
typeid
的结果是一个常量对象的引用,该对象的类型是标准库类型type_info
或者type_info
的公有派生类型。
// 向表达式提问:对象是什么类型?
// e可以是任意表达式或类型的名字
typeid(e)
顶层
const
被忽略,如果表达式是一个引用,则typeid
返回该引用所引对象的类型。不过当typeid
作用于数组或函数时,并不会执行向指针的标准类型转换,即typeid(arr)
所得的结果是数组而非指针类型。
当运算对象不属于类类型或者是一个不包含任何虚函数的类时,typeid
运算符指示的是运算对象的静态类型。而当运算对象是定义了至少一个虚函数的类的左值时,typeid
的结果直到运行时才会求得。
通常情况下,使用
typeid
比较两条表达式的类型是否相同,或者比较一条表达式的类型是否与指定类型相同:
Derived *dp = new Derived;
Base *bp = dp; // 两个指针都指向Derived对象
// 在运行时比较两个对象的类型
if (typeid(*bp) == typeid(*dp)) {
// bp和dp指向同一类型的对象
}
// 检查运行时类型是否是某种指定的类型
if (typeid(*bp) == typeid(Derived)) {
// bp实际指向Derived对象
}
注意,
typeid
应该作用于对象:
// 下面的检查永远是失败的:bp的类型是指向Base的指针。
if (typeid(bp) == typeid(Derived)) {
// 此处的代码永远不会执行
}
如果表达式的动态类型可能与静态类型不同,则必须在运行时对表达式求值以确定返回的类型。
// 如果p所指的类型不含有虚函数,则p不必非得是一个有效的指针。否则,
// *p将在运行时求值,此时p必须是一个有效的指针。如果p是一个空指针,
// 则typeid(*p)将抛出一个名为bad_typeid的异常。
typeid(*p)
在某些情况下RTTI非常有用,例如当想为具有继承关系的类实现相等运算符时。对于两个对象来说,如果它们的类型相同并且对应的数据成员取值相同,则说这两个对象是相等的。
在类的继承体系中,每个派生类负责添加自己的数据成员,因此派生类的相等运算符必须把派生类的新成员考虑进来。
class Base {
friend bool operator==(const Base &, const Base &);
public:
// Base的接口成员
protected:
// 虚函数的基类版本
virtual bool equal(const Base &) const;
// Base的数据成员和其他用于实现的成员
};
class Derived : public Base {
public:
// Derived的其他接口成员
protected:
bool equal(const Base &) const;
// Derived的数据成员和其他用于实现的成员
};
bool operator==(const Base &lhs, const Base &rhs) {
// 如果typeid不相同,返回false;否则虚调用equal。
return typeid(lhs) == typeid(rhs) && lhs.equal(rhs);
}
// 虚函数的基类版本和派生类版本必须具有相同的形参类型,此时,equal函数
// 将只能使用基类的成员,而不能比较派生类独有的成员。因此,需要借助RTTI
// 来解决上述问题。
bool Derived::equal(const Base &rhs) const {
// 此时清楚这两个类型是相等的,所以转换过程不会抛出异常。
auto r = dynamic_cast<const Derived&>(rhs);
// 执行比较两个Derived对象的操作并返回结果
}
// 无须类型转换,*this和形参都是Base类型。
bool Base::equal(const Base &rhs) const {
// 执行比较Base对象的操作
}
type_info
的精确定义随着编译器的不同而略有差异。不过,c++标准规定其必须定义在typeinfo
头文件中,并且至少提供以下操作:
type_info
一般作为一个基类出现,所以应该提供一个公有的虚析构函数。当编译器希望提供额外的类型信息时,通常在type_info
的派生类中完成。type_info
没有默认构造函数,而且它的拷贝和移动构造函数以及赋值运算符都被定义为删除的。因此,创建type_info
对象的唯一途径是使用typeid
运算符。
枚举属于字面值常量类型,可以将一组整型常量组织在一起。C++包含两种枚举:限定作用域的和不限定作用域的。
// 限定作用域
enum class open_modes { input, output, append };
// 不限定作用域
enum color { red, yellow, green };
如果
enum
是未命名的,则只能在定义该enum
时定义它的对象。
在限定作用域的枚举类型中,枚举成员的名字遵循常规的作用域准则,并且在枚举类型的作用域外是不可访问的。
在不限定作用域的枚举类型中,枚举成员的作用域与枚举类型本身的作用域相同。
enum color { red, yellow, green }; // 不限定作用域的枚举类型
enum stoplight { red, yellow, green }; // 错误:重复定义了枚举成员。
enum class peppers { red, yellow, green }; // 正确:枚举成员被隐藏了。
color eyes = green; // 正确:不限定作用域的枚举类型的枚举成员位于有效的作用域中。
// 错误:peppers的枚举成员不在有效的作用域中。color::green在有效的作用域中,但是类型错误。
peppers p = green;
color hair = color::red; // 正确:允许显式地访问枚举成员。
peppers p2 = peppers::red; // 正确:使用peppers的red。
默认情况下,枚举值从0开始,依次加1。不过也可以指定专门的值。如果没有显式地提供初始值,则当前枚举成员的值等于之前枚举成员的值加1。
enum class intTypes {
charTyp = 8, shortTyp = 16, intTyp = 16,
longTyp = 32, long_longTyp = 64
};
枚举成员是
const
,因此在初始化枚举成员时提供的初始值必须是常量表达式。因此:
- 可以定义枚举类型的
constexpr
变量:
constexpr intTypes charbits = intTypes::charTyp;
- 可以将一个
enum
作为switch
的表达式,而将枚举值作为case
标签。- 还能将枚举类型作为一个非类型模板形参使用。
- 或者在类的定义中初始化枚举类型的静态数据成员。
open_modes om = 2; // 错误:2不属于类型open_modes。
om = open_modes::input; // 正确:input是open_modes的一个枚举成员。
一个不限定作用域的枚举类型的对象或枚举成员自动地转换成整型:
int i = color::red; // 正确:不限定作用域的枚举类型的枚举成员隐式地转换成int。
int j = peppers::red; // 错误:限定作用域的枚举类型不会进行隐式转换。
尽管每个
enum
都定义了唯一的类型,但实际上enum
是由某种整数类型表示的。
在c++11新标准中,可以在enum
的名字后加上冒号以及想在该enum
中使用的类型:
enum intValues : unsigned long long {
charTyp = 255, shortTyp = 65535, intTyp = 65535,
longTyp = 4294967295UL,
long_longTyp = 18446744073709551615ULL
};
如果没有指定
enum
的潜在类型,则默认情况下限定作用域的enum
成员类型是int
。不限定作用域的枚举类型的枚举成员不存在默认类型,只知道成员的潜在类型足够大,肯定能够容纳枚举值。
如果指定了枚举成员的潜在类型(包括对限定作用域的enum
的隐式指定),则一旦某个枚举成员的值超出了该类型所能容纳的范围,将引发程序错误。
指定enum
潜在类型的能力使得可以控制不同实现环境中使用的类型,将可以确保在一种实现环境中编译通过的程序所生成的代码与其他实现环境中生成的代码一致。
在c++11新标准中,可以提前声明
enum
,其前置声明(无论隐式地还是显式地)必须指定其成员的大小:
enum intValues : unsigned long long; // 不限定作用域的,必须指定成员类型。
enum class open_modes; // 限定作用域的枚举类型可以使用默认成员类型int
同样的,
enum
的声明和定义必须匹配,因此成员的大小必须一致。而且,不能在同一个上下文中先声明一个不限定作用域的enum
名字,然后再声明一个同名的限定作用域的enum
:
// 错误:所有的声明和定义必须对该enum是限定作用域的还是不限定作用域的保持一致。
enum class intValues;
enum intValues; // 错误:intValues已经被声明成限定作用域的enum。
enum intValues : long; // 错误:intValues已经被声明成int。
要想初始化一个
enum
对象,必须使用该enum
类型的另一个对象或者它的一个枚举成员。
// 不限定作用域的枚举类型,潜在类型因机器而异。
enum Tokens { INLINE = 128, VIRTUAL = 129 };
void ff(Tokens);
void ff(int);
int main() {
Tokens curTok = INLINE;
ff(128); // 精确匹配ff(int)
ff(INLINE); // 精确匹配ff(Tokens)
ff(curTok); // 精确匹配ff(Tokens)
return 0;
}
但是可以将一个不限定作用域的枚举类型的对象或枚举成员传给整型形参。此时,
enum
的值提升成int
或更大的整型,实际提升的结果由枚举类型的潜在类型决定:
// Tokens中较大的那个值是129,该枚举类型可以用unsigned char来表示,
// 因此很多编译器使用unsigned char作为Tokens的潜在类型。不管Tokens
// 的潜在类型到底是什么,它的对象和枚举成员都提升成int。尤其是,枚举成员
// 永远不会提升成unsigned char,即使枚举值可以用unsigned char存储也是如此。
void newf(unsigned char);
void newf(int);
unsigned char uc = VIRTUAL;
newf(VIRTUAL); // 调用newf(int)
newf(uc); // 调用newf(unsigned char)
成员指针是指可以指向类的非静态成员的指针。其囊括了类的类型以及成员的类型。当初始化一个这样的指针时,令其指向类的某个成员,但是不指定该成员所属的对象;直到使用成员指针时,才提供成员所属的对象。
class Screen {
public:
typedef std::string::size_type pos;
char get_cursor() const { return contents[cursor]; }
char get() const;
char get(pos ht, pos wd) const;
private:
std::string contents;
pos cursor;
pos height, width;
};
// pdata可以指向一个常量(非常量)Screen对象的string成员。
const string Screen::*pdata;
当初始化一个成员指针(或者向它赋值)时,需指定它所指的成员。
// 将取地址运算符作用于类的成员而非内存中的一个该类对象
pdata = &Screen::contents;
// C++11新标准最简单的方法是使用auto或decltype
auto pdata = &Screen::contents;
必须清楚的一点是,当初始化一个成员指针或为成员指针赋值时,该指针并没有指向任何数据。成员指针指定了成员而非该成员所属的对象,只有当解引用成员指针时才提供对象的信息。
有两种成员指针访问运算符:.*
和->*
:
Screen myScreen, *pScreen = &myScreen;
// .*解引用pdata以获得myScreen对象的contents成员
auto s = myScreen.*pdata;
// ->*解引用pdata以获得pScreen所指对象的contents成员
s = pScreen->*pdata;
这些运算符执行两部操作:首先解引用成员指针以得到所需的成员;然后像成员访问运算符一样,通过对象(
.*
)或指针(->*
)获取成员。
因为数据成员一般情况下是私有的,所以通常不能直接获得数据成员的指针。如果确实有这样的需求,最好定义一个函数,令其返回值是指向该成员的指针:
// 之前对于pdata的使用必须位于类的成员或友元内部
class Screen {
public:
// data是一个静态成员,返回一个成员指针。
static const std::string Screen::*data() {
return &Screen::contents;
}
// 其他成员保持一致
};
const string Screen::*pdata = Screen::data();
auto s = myScreen.*pdata;
要想创建一个指向成员函数的指针,最简单的办法是使用
auto
来推断类型:
auto pfm = &Screen::get_cursor;
指向成员函数的指针也需要指定目标函数的返回类型和形参列表。如果成员函数是
const
成员或者引用成员,则必须将const
限定符或引用限定符包含进来。
如果成员存在重载的问题,则必须显式地声明函数类型以明确指出想要使用的是哪个函数:
// 指向两个形参的get。其中指针两端的括号必不可少,否则声明的是一个函数。
char (Screen::*pfm2)(Screen::pos, Screen::pos) const;
pfm2 = &Screen::get;
和普通函数指针不同的是,在成员函数和指向该成员的指针之间不存在自动转换规则:
pmf = &Screen::get; // 必须显式地使用取地址运算符
pmf = Screen::get; // 错误:在承运商和指针之间不存自动转换规则。
Screen myScreen, *pScreen = &myScreen;
// 通过pScreen所指的对象调用pmf所指的函数
char c1 = (pScreen->*pmf)();
// 通过myScreen对象将实参0, 0传给含有两个形参的get函数
char c2 = (myScreen.*pmf)(0, 0);
通过使用类型别名,可以令含有成员指针的代码更易读写:
using Action = char (Screen::*)(Screen::pos, Screen::pos) const;
Action get = &Screen::get;
可以将指向成员函数的指针作为某个函数的返回类型或形参类型。其中,指向成员的指针形参也可以拥有默认实参:
Screen &action(Screen &, Action = &Screen::get);
Screen myScreen;
// 等价的调用:
action(myScreen); // 使用默认实参
action(myScreen, get); // 使用之前定义的变量get
action(myScreen, &Screen::get); // 显式地传入地址
对于普通函数指针和指向成员函数的指针来说,一种常见的用法是将其存入一个函数表当中。如果一个类含有几个相同类型的成员,则这样一张表可以帮助从这些成员中选择一个。
class Screen {
public:
// 其他成员保持一致
// Action是一个指针,可以用任意一个光标移动函数对其赋值。
using Action = Screen &(Screen::*)();
// 光标移动函数
Screen &home();
Screen &forward();
Screen &back();
Screen &up();
Screen &down();
enum Directions { HOME, FORWARD, BACK, UP, DOWN };
// 可以调用任意一个光标移动函数并执行对应的操作
Screen &move(Directions);
private:
// 函数表:依次保存每个光标移动函数的指针,这些函数
// 将按照Directions中枚举成员对应的偏移量存储。
static Action Menu[];
};
// Directions中的默认值从0开始,与数组对应。
Screen &Screen::move(Directions cm) {
// 运行this对象中索引值为cm的元素
return (this->*Menu[cm])(); // Menu[cm]指向一个成员函数
}
Screen::Action Screen::Menu[] = {
&Screen::home,
&Screen::forward,
&Screen::back,
&Screen::up,
&Screen::down
};
Screen myScreen;
myScreen.move(Screen::HOME); // 调用myScreen.home
myScreen.move(Screen::DOWN); // 调用myScreen.down
由于对成员函数的指针进行调用需要绑定到特定的对象上,因此,其不是一个可调用对象,所以不能直接将一个指向成员函数的指针传递给算法。
从指向成员函数的指针获取可调用对象的一种方法是使用标准库模板
function
。
当定义一个function
对象时,必须指定该对象所能表示的函数类型,即可调用对象的形式。如果可调用对象是一个成员函数,则第一个形参必须表示该成员是在哪个(一般是隐式的)对象上执行的。同时,提供给function
的形式中还必须指明对象是否是以指针或引用的形式传入的。
function<bool (const string &)> fcn = &string::empty;
find_if(svec.begin(), svec.end(), fcn);
vector<string*> pvec;
function<bool (const string *)> fp = &string::empty;
// fp接受一个指向string的指针,然后使用->*调用empty。
find_if(pvec.begin(), pvec.end(), fp);
和
function
不同的是,通过使用标准库功能mem_fn
来让编译器负责推断成员的类型,从而使得用户无须显式地指定:
// 使用mem_fn(&string::empty)生成一个可调用对象:
// 接受一个string实参,返回一个bool值。
find_if(svec.begin(), svec.end(), mem_fn(&string::empty));
mem_fn
生成的可调用对象可以通过对象调用,也可以通过指针调用:
// 可以认为mem_fn生成的可调用对象含有一对重载的函数调用运算符:
// 一个接受string*,另一个接受string&。
auto f = mem_fn(&string::empty); // f接受一个string或者一个string*
f(*svec.begin()); // 正确:传入一个string对象,f使用.*调用empty。
f(&svec[0]); // 正确:传入一个string的指针,f使用->*调用empty。
还可以使用
bind
从成员函数生成一个可调用对象:
// 选择范围中的每个string,并将其bind到empty的第一个隐式实参上。
auto it = find_if(svec.begin(), svec.end(),
bind(&string::empty, std::placeholders::_1));
和
function
类似的地方是,当使用bind
时,必须将函数中用于表示执行对象的隐式形参转换成显式的。和mem_fn
类似的地方是,bind
生成的可调用对象的第一个实参既可以是指针,也可以是引用。
auto f = bind(&string::empty, std::placeholders::_1);
f(*svec.begin()); // 正确:实参是一个string,f使用.*调用empty。
f(&svec[0]); // 正确:实参是一个string的指针,f使用->*调用empty。
嵌套类常用于定义作为实现部分的类。其是一个独立的类,与外层类基本没什么关系。特别是,外层类的对象和嵌套类的对象是相互独立的。
嵌套类的名字在外层类作用域中是可见的,在外层类作用域之外不可见。因此,嵌套类的名字不会和别的作用域中的同一个名字冲突。
嵌套类在其外层类中定义了一个类型成员。和其他成员类似,该类型的访问权限(public
、protected
、private
)由外层类决定。
class TextQuery {
public:
class QueryResult; // 嵌套类稍后定义
// 其他成员保持一致
};
当在外层类之外定义一个嵌套类时,必须以外层类的名字限定嵌套类的名字:
class TextQuery::QueryResult {
// 位于类的作用域内,因此不必对QueryResult形参进行限定。
friend std::ostream &print(std::ostream &, const QueryResult &);
public:
// 无须定义QueryResult::line_no。嵌套类可以直接使用外层类
// 的成员,无须对该成员的名字进行限定。
QueryResult(std::string,
std::shared_ptr<std::set<line_no>>,
std::shared_ptr<std::vector<std::string>>);
// 其他成员保持一致
};
嵌套类在其外层类之外完成真正的定义之前,它都是一个不完全类型。
TextQuery::QueryResult::QueryResult(string s,
shared_ptr<set<line_no>> p,
shared_ptr<vector<string>> f) : sought(s), lines(p), file(f) {
}
如果嵌套类声明了一个静态成员,则该成员的定义将位于外层类的作用域之外:
int TextQuery::QueryResult::static_mem = 1024;
名字查找的一般规则在嵌套类中同样适用。当然,因为嵌套类本身是一个嵌套作用域,所以还必须查找嵌套类的外层作用域。
由于嵌套类是其外层类的一个类型成员,因此外层类的成员可以像使用任何其他类型成员一样使用嵌套类的名字。
// 返回类型不在类的作用域中,因此必须指明QueryResult是一个嵌套类。
TextQuery::QueryResult
TextQuery::query(const string &sought) const {
// 如果没有找到sought,则返回set的指针。
static shared_ptr<set<line_no>> nodata(new set<line_no>);
// 使用find而非下标以避免向wm中添加单词
auto loc = wm.find(sought);
if (loc == wm.end()) {
return QueryResult(sought, nodata, file); // 没有找到
} else {
return QueryResult(sought, loc->second, file);
}
}
嵌套类的对象只包含嵌套类定义的成员;同样,外层类的对象只包含外层类定义的成员:
// 第二条return语句:使用了TextQuery的数据成员。因为在一个QueryResult对象中不包含
// 其外层类的成员,所以必须使用上述成员构造返回的QueryResult对象。
return QueryResult(sought, loc->second, file);
一个
union
可以有多个数据成员,但是在任意时刻只有一个数据成员可以有值。当给union
的某个成员赋值之后,该union
的其他成员就变成未定义的状态了。分配给一个union
对象的存储空间至少要能容纳它的最大的数据成员。
union
不能含有引用类型的成员,除此之外,它的成员可以是绝大多数类型。在c++11新标准中,含有构造函数或析构函数的类类型也可以作为union
的成员类型。
union
可以为其成员指定public
、protected
和private
等保护标记。默认情况下,union
的成员都是公有的。
union
可以定义包括构造函数和析构函数在内的成员函数。但是由于union
既不能继承自其他类,也不能作为基类使用,所以在union
中不能含有虚函数。
union
提供了一种有效的途径使得可以方便地表示一组类型不同的互斥值。
// 假设需要处理一些不同种类的数字数据和字符数据
union Token {
char cval;
int ival;
double dval;
};
默认情况下
union
是未初始化的。如果提供了初始值,则该初始值被用于初始化第一个成员。
Token first_token = { 'a' }; // 初始化cval成员
Token last_token; // 未初始化的Token对象
Token *pt = new Token; // 指向一个未初始化的Token对象的指针
last_token.cval = 'z';
pt->ival = 42;
一旦定义了一个匿名
union
,编译器会自动地为该union
创建一个未命名的对象:
union { // 匿名union
char cval;
int ival;
double dval;
}; // 定义了一个未命名的对象,可以直接访问它的成员。
cval = 'c';
ival = 42; // 该对象当前保存的值是42
匿名
union
不能包含受保护的成员或私有成员,也不能定义成员函数。
当
union
包含的是内置类型的成员时,可以使用普通的赋值语句改变其保存的值。但是对于含有特殊类类型成员的union
,如果想将值改为类类型成员对应的值,或者将类类型成员的值改为一个其他值,则必须分别构造或析构该类类型的成员。
当union
包含的是内置类型的成员时,编译器将按照成员的次序依次合成默认构造函数或拷贝控制成员。但是如果union
含有类类型的成员,并且该类型自定义了默认构造函数或拷贝控制成员,则编译器将为union
合成对应的版本并将其声明为删除的。
通常情况下,把含有类类型成员的
union
内嵌在另一个类当中。这个类可以管理并控制与union
的类类型成员有关的状态转换。
为了追踪union
中到底存储了什么类型的值,通常会定义一个独立的对象,该对象称为union
的判别式。可以使用判别式辨认union
存储的值。
class Token {
public:
// 因为union含有一个string成员,所以Token必须定义拷贝控制成员。
Token() : tok(INT), ival(0) {}
// 当在拷贝构造函数中调用copyUnion时,本对象的union成员将被默认初始化,
// 这意味着编译器会初始化union的第一个成员。因为string不是第一个成员,
// 所以无需考虑其他因素,但是赋值运算符则不一样,有可能union已经存储了一个string。
Token(const Token &t) : tok(t.tok) { copyUnion(t); }
Token &operator=(const Token &);
// 因为union含有一个定义了析构函数的成员,所以必须为union也定义一个析构函数
// 以进行销毁操作。作为union组成部分的类成员无法自动销毁。因为析构函数不清楚
// union存储的值是什么类型,所以无法确定应该销毁哪个成员。
~Token() {
if (tok == STR) {
sval.~string();
}
}
// 下面的赋值运算符负责设置union的不同成员
Token &operator=(const std::string &);
Token &operator=(char);
Token &operator=(int);
Token &operator=(double);
private:
// 为了保持union与判别式同步,将判别式也作为Token的成员。
enum {
INT, CHAR, DBL, STR
} tok; // 判别式
union { // 匿名union
char cval;
int ival;
double dval;
std::string sval;
}; // 每个Token对象含有一个该未命名union类型的未命名成员
// 检查判别式,然后酌情拷贝union成员。
void copyUnion(const Token &);
};
// 和析构函数一样,在为union赋新值前必须首先销毁string。
// double和char版本的赋值运算符与int非常相似。
Token &Token::operator=(int i) {
// 如果当前存储的是string,则释放它。
if (tok == STR) {
sval.~string();
}
ival = i; // 为成员赋值
tok = INT; // 更新判别式
return *this;
}
Token &Token::operator=(const std::string &s) {
if (tok == STR) { // 如果当前存储的string,可以直接赋值。
sval = s;
} else {
// 否则找不到一个已存在的string对象来调用赋值运算符。此时,
// 必须先利用定位new表达式在内存中为sval构造一个string,然后
// 进行初始化操作。
new(&sval) string(s);
}
tok = STR; // 更新判别式
return *this;
}
// 拷贝构造函数和赋值运算符也需要先检验判别式以明确拷贝
// 所采用的方式,因此,定义一个成员来方便后续操作。
void Token::copyUnion(const Token &t) {
switch (t.tok) {
case Token::INT:
ival = t.ival;
break;
case Token::CHAR:
cval = t.cval;
break;
case Token::DBL:
dval = t.dval;
break;
case Token::STR:
new(&sval) std::string(t.sval);
break;
}
}
// 赋值运算符必须处理string成员的三种可能情况:
// 1.左侧运算对象和右侧运算对象都是string。
// 2.两个运算对象都不是string。
// 3.只有一个运算对象是string。
Token &Token::operator=(const Token &t) {
// 如果此对象的值是string而t的值不是,则必须释放原来的string。
if (tok == STR && t.tok != STR) {
sval.~string();
}
if (tok == STR && t.tok == STR) {
sval = t.sval; // 无须构造一个新string
} else {
copyUnion(t); // 如果t.tok是STR,则需要构造一个string。
}
tok = t.tok;
return *this;
}
类可以定义在某个函数的内部,称这样的类为局部类。局部类定义的类型只在定义它的作用域内可见。其成员(包括函数在内)都必须完整定义在类的内部。因此,在实际的编程过程中,局部类的成员函数的复杂性不可能太高,否则就很难读懂它了。
类似的,在局部类中也不允许声明静态数据成员,因为没法定义这样的成员。
局部类只能访问外层作用域定义的类型名、静态变量以及枚举成员。如果局部类定义在某个函数的内部,则该函数的普通局部变量不能被该局部类使用:
int a, val;
void foo(int val) {
static int si;
enum Loc { a = 1024, b };
// Bar是foo的局部类
struct Bar {
Loc locVal; // 正确:使用一个局部类型名。
int barVal;
void fooBar(Loc l = a) { // 正确:默认实参时Loc::a。
barVal = val; // 错误:val是foo的局部变量。
barVal = ::val; // 正确:使用一个全局对象。
barVal = si; // 正确:使用一个静态局部对象。
locVal = b; // 正确:使用一个枚举成员。
}
};
}
外层函数对局部类的私有成员没有任何访问权限。当然,局部类可以将外层函数声明为友元;或者更常见的情况是局部类将其成员声明成公有的。
在程序中有权访问局部类的代码非常有限。局部类已经封装在函数作用域中,通过信息隐藏进一步封装就显得没什么必要了。
局部类内部的名字查找次序与其他类相似。
可以在局部类的内部再嵌套一个类。此时,嵌套类的定义可以出现在局部类之外。不过,嵌套类必须定义在与局部类相同的作用域中。
void foo() {
class Bar {
public:
// ...
class Nested; // 声明Nested类
};
// 定义Nested类
class Bar::Nested {
// ...
};
}
局部类的嵌套类也是一个局部类,必须遵循局部类的各种规定。此时,嵌套类的所有成员都必须定义在嵌套类的内部。