基本内置类型:
- 空类型(void)
- 算术类型(arithmetic type)
- 整型(integral type)
- 布尔型(bool)
- 字符型(char、signed char、unsigned char、wchar_t、char16_t、char32_t):ASCLL、Unicode。
- 其他整型(short、int、long、long long)
- 有符号的(signed):补码
- 无符号的(unsigned):原码
- 浮点型
- 单精度(float)
- 双精度(double)
- 扩展精度(long double)
- 整型(integral type)
注意事项:
- 当一个算数表达式既有有符号类型又有无符号类型时,有符号数会自动转换成无符号数。
字面值常量:每个字面值常量对应一种数据类型,其形式和值决定了它的数据类型。
- 整形字面值
- 十进制:20
- 八进制:024
- 十六进制:0x14
- 浮点型字面值,默认是一个 double
- 3.14159
- 3.14159E0
- 字符和字符串字面值
- ‘a’
- “Hello World!”
- 转义序列:
\n \t \\ \? \" \'
…… - 泛化的转义序列:
\7 \12 \40 \0 \x4d
……
- 布尔字面值
- true
- false
- 指针字面值
- nullptr
- 0
- NULL
注意事项:
- 通过前缀和后缀,可以改变整型、浮点型和字符型字面值的默认类型。
变量
- 理解初始化和赋值的概念。
- 初始化可使用:= 、()、{}。
- 最后一种为列表初始化,当初始值存在丢失信息的风险,编译器将报错。
- {} 也可用来为对象赋新值。
- = 也用作赋值。
- 内置类型的变量未被显式初始化时,定义于任何函数体之外的变量被初始化为0,定义在函数体内部的内置类型变量将不被初始化。一个未被初始化的内置类型变量的值是未定义的!
- 类的对象如果没有显式初始化,则其值由类确定。
- 声明一个变量而非定义它,添加 extern 关键字,且不要显式初始化变量。它告诉编译器,变量在其他地方被定义,可用于分离式编译。
- C++ 的标识符由字母、数字和下划线组成,必须以字母或下划线开头,对长度无限制,对大小写敏感。不可使用标准库的保留字和关键字等。
- 名字的作用域:
- 全局作用域:定义在所有函数体之外的名字拥有全局作用域(global scope),名字声明之后则在整个程序的范围内都可使用。例如,main。
- 块作用域(block scope):从声明处开始到块结束。
- 内层作用域能使用也能重新定义(覆盖)外层作用域的名字。
- 作用域访问运算符:
::
,左侧为空时,表示全局作用域。
复合类型
- 引用
- 引用是对象的别名,且必须被初始化。定义引用时,程序把引用和它的初始值绑定在一起,而不是将初始值拷贝给引用,且它们将一直绑定在一起。
- 引用的类型需要和与之绑定的对象严格匹配(除两种例外情况),而且引用只能绑定在对象上,而不能与字面值或某个表达式的计算结果绑定在一起(常量引用可以)。
- 指针
- 和引用一样,除了两种例外情况,指针的类型必须与它所指向的对象的类型相匹配。
- 空指针不指向任何对象。
- void* 指针,一种特殊的指针类型,可用于存放任意对象的地址。但对该地址中的具体对象并不了解,仅仅是内存空间,因此不能直接操作 void* 指针所指的对象。可以做:比较、作为函数的输入输出、赋给另一个 void* 指针。
- 指向指针的指针,……
- 指向指针的引用:
int i = 42, *p = &i, *&r = p
。
- 理解复合类型的声明:一个基本数据类型和一组声明符,一个声明符对应一个变量,声明符的形式可以不同,一条定义语句可能定义出不同类型的变量:
int i = 1024, *p = &i, &r = i;
。
const
- const 定义常量对象,一旦创建后其值不能改变,所以 const 对象必须初始化!可运行时,也可编译时初始化。
- 默认状态下,const 对象仅在文件内有效。在多个文件之间共享 const 对象,可在变量的定义之前添加 extern 关键字,并在其他文件中添加 extern 声明。
- 常量引用是对 const 的引用,一种底层 const,表示引用所指的对象被看作为常量,因而不能修改引用所绑定的对象。
- 初始化常量引用时允许任意表达式,例如,非常量的对象、字面值、一个一般表达式。但不能是临时量。注意区分一般表达式和临时量。
- 指针和 const
- 顶层 const:指针本身是常量。
- 底层 const:指针所指的对象被看作常量。
- 指向常量的指针或引用,仅是指针或引用的自以为是,自认为指向了常量,从而不去改变所指的对象罢了。
constexpr 和常量表达式
- 常量表达式是指值不会改变(条件1:常量)并且在编译过程就能得到计算结果的表达式(条件2:编译时已知)。
- 字面值、常量表达式初始化的 const 对象都是常量表达式。
- 将变量声明为 constexpr 类型以便由编译器来验证变量的值是否是一个常量表达式。声明为 constexpr 的变量一定是一个常量,而且必须用常量表达式初始化,而声明为 const 的变量不一定是常量表达式(其值可在运行时确定)。
- 算术类型、引用和指针都属于字面值类型。自定义类、IO库、string 则不属于字面值类型,也就不能被定义成 constexpr。
- 一个 constexpr 指针的初始值必须是 nullptr 或者 0,或者是存储于某个固定地址中的对象(定义于所有函数体之外的变量或静态变量,constexpr 也能绑定到这样的变量上)。
- constexpr 仅对指针有效、与指针所指的对象无关,因为 constexpr 把它所定义的对象置为了顶层 const:
constexpr int *q = nullptr;
和constexpr const int *p = &ci;
。
处理类型
- 类型别名:
typedef oldName(int/double) newName;
using newName = oldName(int/double);
- auto
- auto 让编译器通过初始值来推算变量的类型,auto 定义的变量必须有初始值。
- 使用 auto 声明多个变量,该一条声明语句中的所有变量的初始基本数据类型必须一样。
- 当引用被用作初始值时,会推断出引用所代表的实际对象的类型。
- auto 会忽略顶层 const,保留底层 const。
- 当需要推断出的 auto 是一个顶层 const ,需要明确指出:
const int ci = 0; const auto f = ci;
- 设置一个类型为 auto 的引用时,初始值中的顶层常量属性仍然保留。
- decltype
- decltype 返回表达式的类型,编译器分析但不实际计算表达式的值。
- 与 auto 截然不同,对于变量表达式,decltype 保留顶层 const 和引用本身,即 decltype 返回变量的类型时包括顶层 const 和引用在内。
- 对于左值表达式,decltype 返回引用。例如,加括号的变量和解引用的指针。
数组
- 默认情况下,数组的元素被默认初始化。在函数内部定义了某种内置类型的数组,那么默认初始化会令数组含有未定义的值。
- 在很多用到数组名字的地方,编译器都会自动地将其替换为一个指向数组首元素的指针。
- 当使用数组作为一个 auto 变量的初始值时,推断得到的类型时指针而非数组。
- 当使用 decltype 关键字时,返回的类型是数组,上述转换不会发生。
- 当数组使用下标运算符时,编译器会执行到指针的转换,此时内置的下标运算符可以是负值,而标准库限定使用的下标必须是无符号类型。
- 多维数组
- 实际上是数组的数组,使用范围 for 语句处理多维数组时,除了最内层的循环外,其他所有循环的控制变量都应该是引用类型。
- 当使用多维数组的名字时,自动将其转换为指向数组首元素(也是个数组类型)的指针,即第一个内层数组的指针。
1
2
3
4
5
6
7
8
9
10
int ia[3][4];
int (*p)[4] = ia;
p = &ia[2];
for(auto p = ia; p != ia + 3; ++p) {
// p 是指向 4 个整数数组的指针
for(auto q = *p; q != *p + 4; ++q)
cout << *q << ' ';// q 是指向整数数组首元素的指针
cout << endl;
}
sizeof
类型转换
异常
函数
const
const
是 C++ 非常重要的一个特性,虽然平时使用得比较多,但是重新回去看书才发现一些概念性的东西很容易遗忘了,特整理记录一下。
1. const
有时候需要这样一种变量,它的值不能被改变,使用它的地方不少,当我们要对其进行调整时很容易修改,但也要随时警惕防止程序修改这个值。为满足这一要求,可用关键字 const
对变量的类型加以限定:
1
const int bufSize = 512; // 输入缓冲区大小
编译器将在编译过程中把用到变量bufSize
的地方都替换成对应的值 512
。const
对象的值一经创建就不能再改变,所以必须初始化,同时只能在 const
类型的对象上执行不改变其内容的操作,常量特性仅在执行改变内容的操作时发挥作用。
1
const int k; // error
默认情况下,const
对象被设定为仅在文件内有效。当多个文件出现同名的 const
变量时,其实等同于在不同文件中分别定义了独立的变量。若要只在一个文件中定义 const
, 而在其他多个文件中声明并使用它,则需对 const
变量不管是声明还是定义都添加 extern
关键字,这样只需定义一次就可以了:
1
2
3
4
// file_1.cc 定义并初始化了一个常量,该常量能被其他文件访问
extern const int bufSize = fcn();
// file_1.h 头文件
extern const int bufSize; // 与 file_1.cc 中定义的 bufSize 是同一个
2. 指向const
的指针和引用
将引用绑定到 const
对象上,称之为对常量的引用(reference to const),习惯性叫做常量引用。对常量的引用不能被用作修改它所绑定的对象,允许为一个常量引用绑定非常量的对象、字面值,甚至是一般表达式:
1
2
3
4
5
int i = 42;
const int &r1 = i;
const int &r2 = 42;
const int &r3 = r1 * 2;
int &r4 = r1 * 2; // 错误: r4 是一个普通的非常量引用
常量引用仅对引用可参与的操作做出了限定,对于引用的对象本身是不是一个常量未作限定。因为对象也可能是个非常量,所以允许通过其他途径改变它的值。
指向常量的指针(pointer to const) 不能用于改变其所指对象的值,要想存放常量对象的地址,只能使用指向常量的指针。允许令一个指向常量的指针指向一个非常量对象,同常量引用一样,这个非常量对象的值可通过其他途径改变。
3. 顶层/底层 const
允许把指针本身定为常量,常量指针必须初始化,且一旦初始化完成,则它的值(也就是存放在指针中的那个地址)就不能再改变了。把*
放在const
关键字之前用以说明指针是一个常量,不变的是指针本身的值而非指向的那个值:
1
2
3
4
5
int errNumb = 0;
int *const curErr = &errNumb; // curErr 将一直指向 errNumb
const double pi = 3.14159;
const double *const pip = π // pip 是一个指向常量对象的常量指针
// 要想弄清楚这些声明的含义,从右向左阅读。
指针本身是一个常量并不意味着不能通过指针修改其所指的值,能否这样做完全依赖于所指对象的类型。
指针本身是不是常量以及指针所指的是不是一个常量就是两个相互独立的问题。用名词顶层const
(top-level const) 表示指针本身是个常量,而用名词底层 const
(low-level const) 表示指针所指对象是一个常量。
更一般的,顶层const
可以表示任意的对象是常量,这一点对任何数据类型都适用,如算术类型、类、指针等。底层 const
则与指针和引用等复合类型的基本类型部分有关。指针比较特殊,两种皆可以是。
当执行对象拷贝操作时,顶层const
(拷出)不受影响(顶层const
除了初始化外,不再存在拷入操作)。拷入和拷出的对象必须具有相同的底层const
,或者说两个对象的数据类型必须能够转换,非常量可以转换成常量,反之不行。
4. constexpr
常量表达式( const expression ) 是指值不会改变并且在编译过程就能得到计算结果的表达式。字面值属于常量表达式,用常量表达式初始化的const
对象也是常量表达式。允许将变量声明为 constexpr
类型以便由编译器来验证变量的值是否是一个常量表达式。声明为constexpr
的变量一定是一个常量,且必须用常量表达式初始化,允许用 constexpr
函数去初始化 constexpr
变量:
1
2
3
constexpr int mf = 20;
constexpr int limit = mf + 1;
constexpr int sz = size(); // 只有当 size 是一个 constexpr 函数时才是一条正确的声明语句
一个 constexpr
指针的初始值必须是 nullptr
或者 0
,或者是存储于某个固定地址中的对象。注意,函数体内定义的变量一般来说并非存放在固定地址中,定义于所有函数体之外的对象其地址固定不变。限定符 constexpr
仅对指针有效,与指针所指的对象无关, constexpr
把它所定义的对象置为了顶层 const
,与其他常量指针类似,constexpr
指针既可以指向常量也可以指向一个非常量:
1
2
const int *p = nullptr; // p 是一个指向整型常量的指针
constexpr int *q = nullptr; // q 是一个指向整数的常量指针
1
2
3
4
5
int j = 0;
constexpr int i = 42;
// i 和 j 都必须定义在函数体之外
constexpr const int *p = &i; // p 是常量指针,指向整型常量
constexpr int *p1 = &j; // p1 是常量指针,指向整数 j
auto and decltype
auto
auto
让编译器通过初始值来推算变量的类型,auto
在一条语句中声明多个变量时,该语句中所有变量的初始基本数据类型都必须一样。编译器会适当地改变初始值的结果类型使其更符合初始化规则:
- 引用被用作初始值时,编译器以引用对象的类型作为
auto
的类型。 auto
一般会忽略掉顶层const
,保留底层const
。若要推断出顶层const
需明确指出。- 设置一个类型为
auto
的引用时,初始值中的顶层常量属性仍然保留。
1
2
3
4
5
6
7
8
9
int i = 0;
const int ci = i, &cr = ci;
auto b = ci; // b是一个整数(ci 的顶层 const 特性被忽略了)
auto c = cr; // c是一个整数(cr 是 ci 的别名,ci本身是一个顶层const)
auto d = &i; // d是一个整型指针
auto e = &ci; // e是一个指向整数常量的指针(对常量对象取地址是一种底层 const )
const auto f = ci; // ci推演出int,明确指出 f 是 const int
auto &g = ci; // g是一个整型常量引用,绑定到 ci,保留的。
const auto &j = 42; // 42 推演出int,明确指出 const int , j 是对const int 的引用
decltype
decltype
选择并返回操作数的数据类型。操作数可以是变量、表达式,编译器分析表达式并得到它的类型,却不实际计算表达式的值。
decltype
返回表达式结果对应的类型。- 当操作数是表达式并且该表达式的结果为左值时,
decltype
返回引用。 decltype
返回变量的类型(包括顶层const
和引用在内), 引用从来都作为其所指对象的同义词出现,只有用在decltype
处是一个例外。- 变量是一种作为赋值语句左值的特殊表达式,加括号的变量被当作表达式,所以加括号的变量就会得到引用类型,
decltype((variable))
的结果永远是引用。
1
2
3
int i = 5, *p = &i;
decltype(*p) rp = i; // 解引用运算符生成左值,rp 是 int&
decltype(&p) pp; // 取地址运算符生成右值,pp 是 int**
注意
- 当使用数组作为一个
auto
变量的初始值时,推断得到的类型是指针而非数组。当使用decltype
关键字时,返回的类型是数组。 decltype
作用于某个函数时,它返回函数类型而非指针类型。
lambda and bind
lambda
lambda 是一种可调用对象,可对其使用调用运算符。一个 lambda 表达式表示一个可调用的代码单元,我们可以将其理解为一个未命名的内联函数。lambda 表达式具有如下形式:
1
[capture list](parameter list) -> return type { function body }
可以忽略 parameter list
和 return type
,但必须包含 capture list
和 function body
。如果 lambda 的函数体只是一个 return
语句,则返回类型可从返回的表达式类型推断而来。如果函数体包含任何单一 return
语句之外的内容,且未指定返回类型,则返回 void
。
lambda 不能有默认参数。若要使用 lambda 所在函数中定义的(非 static
)变量,可将局部变量包含在其 capture list
中来指出将会使用这些变量。一个 lambda 可以直接使用局部 static
变量和在它所在函数之外声明的名字。捕获分为值捕获和引用捕获,顾名思义,值捕获的前提是变量可以拷贝,且在 lambda 创建时拷贝。引用捕获需确保 lambda 创建时捕获的引用,在 lambda 执行的时候,被引用的对象是存在的。如果函数返回一个 lambda,由于函数不能返回一个局部变量的引用,此 lambda 不能包含引用捕获。当我们在捕获列表中写一个 & 或 = 时,& 告诉编译器采用捕获引用方式,= 则表示采用值捕获方式,此时局部变量被相应的方式隐式捕获。当我们混合使用显示捕获和隐式捕获,捕获列表中的第一个元素必须是一个 & 或 = ,显(隐)式捕获的变量必须使用与隐(显)式捕获不同的方式。如果我们要改变一个被捕获的变量的值,必须在参数列表尾加上关键字 mutable
,即:
1
[capture list](parameter list) mutable -> return type { function body }
当定义一个 lambda 时,编译器生成一个与 lambda 对应的新的(未命名的)类类型。当向一个函数传递一个 lambda 时,同时定义了一个新类型和该类型的一个对象:传递的参数就是此编译器生成的类类型的未命名对象。当用 auto 定义一个用 lambda 初始化的变量时,定义了一个从 lambda 生成的类型的对象。默认情况下,从 lambda 生成的类都包含一个对应该 lambda 所捕获的变量的数据成员,在 lambda 对象创建时被初始化。
进一步说明 lambda
当我们编写了一个 lambda 后,编译器将该表达式翻译成一个未命名类的未命名对象。在 lambda 表达式产生的类中含有一个重载的函数调用运算符,它的形参列表和函数体与 lambda 表达式完全一样。
当一个 lambda 表达式通过引用捕获变量是,将由程序负责确保 lambda 执行时引用所引的对象确实存在,编译器可以直接使用该引用而无需在 lambda 产生的类中将其存储为数据成员。相反,通过值捕获的变量被拷贝到 lambda 中,这种 lambda 产生的类必须为每个值捕获的变量建立对应的数据成员,同时创建构造函数,令其使用捕获的变量的值来初始化数据成员。
默认情况下 lambda 不能改变它捕获的变量,因此默认情况下,由 lambda 产生的类当中的函数调用运算符是一个 const
成员函数。如果 lambd 被声明为可变(mutable
)的,则调用运算符就不是 const
了。
lambda 表达式产生的类不含默认构造函数、赋值运算符及默认析构函数;它是否含有默认的拷贝/移动构造函数则通常要视捕获的数据成员类型而定。使用带有参数列表的 lambda 时,必须提供相应实参才行。
bind
标准库函数 bind
是一个通用的函数适配器,它接受一个可调用对象,生成一个新的可调用对象来“适应”原对象的参数列表。
1
auto newCallable = bind(callable, arg_list);
newCallable
本身是一个可调用对象,arg_list
是一个逗号分隔的参数列表,对应给定的 callable
的参数,当我们调用 newCallable
时,newCallable
会调用 callable
,并传递给它 arg_list
中的参数。arg_list
中从左到右的参数,对应 callable
的第1,2,…,n 个参数)
arg_list
中的参数可能包含形如 _n
的“占位符”,表示 newCallable
的参数,它们占据了传递给 newCallable
的参数的“位置”。数值 n
表示生成的新的可调用对象中参数的位置:_1
为 newCallable
的第一个参数,_2
为第二个参数,以此类推。名字 _n
定义在命名空间 std
中的 placeholders
命名空间中:
1
using namespace std::placeholders;
bind
拷贝其参数,当我们不能拷贝一个对象时(如 ostream
),希望传递给 bind
一个对象而又不拷贝它,就必须使用标准库 ref
函数。ref
返回一个对象,包含给定的引用,此对象可以是拷贝的。cref
函数生成一个保存 const
引用的类。bind
、ref
和 cref
都定义在头文件 functional
中。
1
2
auto newCallable = bind(callable, ref(std::cout), _2, _1, " ");
// newCallable(_1,_2) 等价于 callable(ref(std::cout), _2, _1, " ")
智能指针
静态内存用来保存局部 static 对象、类 static 数据成员以及定义在任何函数之外的变量。栈内存用来保存定义在函数内的非 static 对象。分配在静态内存或栈内存中的对象由编译器自动创建和销毁。除了静态内存和栈内存,每个程序还拥有一个内存池,这部分内存被称为自由空间( free store )或堆( heap )。程序用堆来存储动态分配( dynamically allocate )的对象。标准库提供了智能指针( smart pointer )类型来管理动态对象,负责自动释放所指向的对象,它们都定义在头文件 memory
中。
shared_ptr
operation | explanation(shared_ptr 和unique_ptr 都支持的操作) |
---|---|
shared_ptr<T> sp | 空智能指针,可以指向类型为T的对象 |
unique_ptr<T> up | 同上 |
p | p作为条件,若p指向一个对象,则为true |
*P | 解引用p,获得它所指的对象 |
p->mem | 等价于 (*p).mem |
p.get() | 返回p中保存的指针。要小心使用,若智能指针释放了其对象,返回的指针所指向的对象也就消失了。不能用返回的指针初始化另一个智能指针或为智能指针赋值,也不能delete此指针。 |
swap(p,q) | 交换 p 和 q 的指针 |
p.swap(q) | 同上 |
operation | explanation(shared_ptr 独有的操作) |
---|---|
make_shared<T>(args) | 返回一个 shared_ptr ,指向一个动态分配的类型为T的对象。使用 args 初始化此对象,args 为空进行值初始化。 |
shared_ptr<T>p(q) | p 是指向 shared_ptr q 的拷贝;此操作会递增 q 中的计数器。q 中的指针必须能转换为 T* 。 |
p = q | p 和 q 都是 shared_ptr ,所保存的指针必须能够相互转换。此操作会递减 p 的引用计数,递增 q 的引用计数;若 p 的引用计数变为0,则将其管理的原内存释放。 |
p.unique() | 若 p.use_count() 为1,返回 true ;否则返回 false 。 |
p.use_count() | 返回与 p 共享对象的智能指针数量;可能很慢,主要用于调试。 |
每个 shared_ptr
都有一个关联的计数器,通常称其为引用计数( reference count )。无论何时我们拷贝一个 shared_ptr
,计数器都会递增。一旦一个 shared_ptr
的计数器变为0, 它就会自动释放自己所管理的对象。shared_ptr
的析构函数会递减它所指向的对象的引用计数,如果引用计数变为0,shared_ptr
的析构函数就会销毁对象,并释放它占用的内存。
我们可以用 new
返回的指针来初始化 shared_ptr
,但是必须使用直接初始化的形式。shared_ptr
使用 delete
释放它所关联的对象,默认情况下,一个用来初始化智能指针的普通指针必须指向动态内存。我们可以提供自己的操作来替代 delete
,使得智能指针绑定到一个指向其他类型的资源的指针上(可以是非动态内存资源)。
1
2
shared_ptr<int> p1 = new int(1024); // 错误:必须使用直接初始化形式
shared_ptr<int> p2(new int(1024)); // 正确
operation | explanation |
---|---|
shared_ptr<T> p(q) | p 管理内置指针 q 所指向的对象;q 必须指向 new 分配的内存,且能够转换成 T* 类型。 |
shared_ptr<T> p(u) | p 从 unique_ptr u 那里接管了对象的所有权;将 u 置为空。 |
shared_ptr<T> p(q, d) | 同上上,另:p 将使用可调用对象 d 来代替 delete 。 |
shared_ptr<T> p(p2, d) | p 是 shared_ptr p2 的拷贝,p 将用可调用对象 d 来代替 delete 。 |
p.reset() | reset 会更新引用计数,若 p 是唯一指向其对象的 shared_ptr ,reset 会释放此对象, |
p.reset(q) | 若传递了可选的参数内置指针 q ,会令 p 指向 q ,否则会将 p 置空, |
p.reset(q, d) | 若还传递了参数 d ,将会调用 d 而不是 delete 来释放 q 。 |
shared_ptr
可以协调对象的析构,仅限于其自身的拷贝之间。推荐使用 make_shared
而不是 new
来初始化 shared_ptr
,避免将同一块内存绑定到多个独立创建的 shared_ptr
上,这些 shared_ptr
共享同一块内存,却不共享引用计数,无法协调对象的析构。特别是在函数参数的传递和返回时,应特别小心。当将一个 shared_ptr
绑定到一个普通指针时,我们就不应该再使用内置指针来访问 shared_ptr
所指向的内存了。使用智能指针可确保在异常发生后资源能被自动地正确释放,内置指针则无法做到。
Note
- 不使用相同的内置指针初始化(或
reset
) 多个智能指针。 - 不
delete
get()
返回的指针。 - 不适用
get()
初始化或reset
另一个智能指针。 - 如果你使用了
get()
返回的指针,记住当最后一个对应的智能指针销毁后,你的指针就变为无效的。 - 如果你使用智能指针管理的资源不是
new
分配的内存,记住传递给它一个删除器。
unique_ptr
一个 unique_ptr
“拥有” 它所指向的对象。某个时刻只能有一个 unique_ptr
指向一个给定对象。当 unique_ptr
被销毁时,它所指向的对象也被销毁。当我们定义一个 unique_ptr
时,需将其绑定到一个 new
返回的指针上,且必须采用直接初始化形式。unique_ptr
不支持普通的拷贝或赋值操作。
operation | explanation |
---|---|
unique_ptr<T> u1 | 空 unique_ptr ,可以指向类型为 T 的对象,u1 使用 delete 来释放它的指针。 |
unique_ptr<T, D> u2 | 同上,u2 使用一个类型为D的可调用对象来释放它的指针。 |
unique_ptr<T,D> u(d) | 同上,用类型为 D 的对象 d 代替 delete 。 |
u = nullptr | 释放 u 指向的对象,将 u 置为空。 |
u.realease() | u 放弃对指针的控制权,返回指针,并将 u 置为空。 |
u.reset() | 释放 u 指向的对象, |
u.reset(q) | 如果提供了内置指针 q ,令 u 指向这个对象;否则将 u 置为空, |
u.reset(nullptr) | 同上。 |
调用 realease
会切断 unique_ptr
和它原来管理的对象间的联系,realease
返回的指针通常被用来初始化另一个智能指针或给另一个智能指针赋值。如果我们不用智能指针来保存 realease
返回的指针,我们的程序就要负责资源的释放。如果我们不保存 realease
返回的指针,我们将会丢失掉指针,realease
也不会释放内存。
weak_ptr
weak_ptr
是一种不控制所指向对象生存期的智能指针,它指向一个 shared_ptr
管理的对象。
operation | explanation |
---|---|
weak_ptr<T> w | 空 weak_ptr 可以指向类型为T的对象。 |
weak_ptr<T> w(sp) | 与 shared_ptr sp 指向相同对象的 weak_ptr 。T 必须能够转换为 sp 指向的类型。 |
w = p | p 可以是一个 shared_ptr 或 一个 weak_ptr 。 |
w.reset() | 将 w 置为空。 |
w.use_count() | 与 w 共享对象的 shared_ptr 的数量。 |
w.expired() | 若 w.use_count() 为 0,返回 true ,否则返回 false 。 |
w.lock() | 如果 w.expire() 为 true ,返回一个空 shared_ptr ;否则返回一个指向 w 的对象的 shared_ptr |
创建一个 weak_ptr
时,要用一个 shared_ptr
来初始化它。weak_ptr
像是一种伴随指针,陪伴着 shared_ptr
,不会改变 shared_ptr
的引用计数。由于对象可能不存在,我们不能直接使用 weak_ptr
访问对象,而必须调用 lock
。
构造函数
constructor 简述
类通过构造函数(constructor)来控制其对象的初始化过程,只要类的对象被创建,就会执行构造函数。构造函数的名字和类名相同,没有返回类型,不能被声明成 const
,因为只有在构造函数完成初始化过程,const
对象才能真正取得其“常量”属性。未显式定义构造函数的类,编译器会隐式地定义一个合成的默认构造函数,这个合成的默认构造函数按如下规则初始化类的数据成员:
- 如果存在类内的初始值,用它来初始化成员。
- 否则,默认初始化该成员。(默认值由数据成员的类型决定,内置类型和复合类型(数组和指针)的值被默认初始化(在块内)将是未定义的,类类型的成员没有默认构造函数则编译器无法初始化该成员)
编译器在类内不包含任何构造函数时,才会为我们生成一个默认构造函数,我们可以通过在不接受任何实参的构造函数后面写上 = default
来要求编译器合成默认构造函数。如果类包含有内置类型或复合类型的成员,只有当这些成员全都被赋予了类内的初始值时,这个类才适合使用合成的默认构造函数,否则就应该使用构造函数初始值列表初始化类的每个成员。当某个数据成员被构造函数初始值列表忽略时,它将以与合成默认构造函数相同的方式隐式初始化,即通过相应的类内初始值初始化(如果存在)或默认初始化。
随着构造函数体一开始执行,类内数据成员的初始化就完成了。构造函数的初始值列表显式地初始化成员,在构造函数体执行之前未被初始化的成员将执行默认初始化。构造函数体内的成员赋值操作与初始化的差异有时候可以忽略(这儿的赋值一般是先初始化后再被赋值),但当成员是 const
或引用或属于某个未定义默认构造函数的类类型时,必须将其初始化,最好的方式就是通过构造函数初始值列表为它们提供初值。
成员的初始化顺序与它们在类定义中出现的顺序的一致,构造函数初始值列表初始值的前后位置关系不会影响实际的初始化顺序。最好令构造函数初始值的顺序与成员声明的顺序保持一致,避免使用某些成员初始化其他成员。
一个为所有参数都提供了默认实参的构造函数,实际上也定义了默认构造函数。
委托构造函数,使用它所属类的其他构造函数执行初始化构造,把它自己的一些或全部职责委托给了其他构造函数。受委托的构造函数先执行,然后执行委托者的构造函数。
默认构造函数的作用
当对象被默认初始化或值初始化时自动执行默认构造函数。
默认初始化:
块作用域内无任何初始值定义一个非静态变量或数组时。
当一个类含有类类型的成员且使用合成的默认构造函数时,该成员被默认初始化。
类类型成员没有在构造函数初始值列表中显式地初始化时。
值初始化:
数组初始化过程中提供的初始值数量少于数组的大小时。
未使用初始值定义一个局部静态变量时。
书写形如
T()
的表达式显式地请求值初始化时。(T 是类型名)
类必须包含一个默认构造函数以便在上述情况下使用。定义了其他构造函数,最好也提供一个默认构造函数。
定义使用默认构造函数初始化的对象时,应去掉对象名之后的空括号对:
1
2
Sales_data obj(); // 错误:声明了一个函数而非对象
Sales_data obj2; // 正确
注意:如果类的某个成员的析构函数是删除的或不可访问的,或是类有一个引用成员,它没有类内初始化器,或是有一个 const
成员,它没有类内初始化器且其类型未显式定义默认构造函数,则该类的默认构造函数被定义为删除的。
隐式的类类型转换
能通过一个实参调用的构造函数定义了一条从构造函数的参数类型向类类型隐式转换的规则,这种构造函数称为转换构造函数(converting constructor)。记住:编译器只会自动执行一步类型转换。
通过将构造函数声明为 explicit
可阻止构造函数定义的隐式转换,编译器将不会在自动转换过程中使用该构造函数。只能在类内声明构造函数时使用 explicit
关键字,在类外部定义时不应重复。注意:我们只能以直接初始化的形式使用 explicit
声明的构造函数。
虽然 explicit
构造函数不能用于隐式转换,但我们可以使用 explicit
的构造函数显式地进行强制转换, static_cast
可以使用 explicit
的构造函数进行转换。
OOP 中的 constructor
派生类能够重用其直接基类定义的构造函数。一个类只初始化它的直接基类,也只继承其直接基类的构造函数。类不能继承默认、拷贝和移动构造函数。如果派生类没有直接定义这些构造函数,编译器将为派生类合成它们。
派生类继承基类构造函数的方式是提供了一条注明了(直接)基类名的 using
声明语句。通常情况下,using
声明语句只是令某个名字在当前作用域可见。而当作用于构造函数时,using
声明语句将领编译器产生代码。对于基类的每个构造函数,编译器都生成一个与之对应的派生类构造函数。这些编译器生成的构造函数刑如:
1
derived(parms) : base(args) { }
其中,derived
是派生类的名字,base
是基类的名字,parms
是构造函数的形参列表,args
将派生类构造函数的形参传递给基类的构造函数。如果派生类含有自己的数据成员,则这些成员将被默认初始化。一个构造函数的 using
声明不会改变该构造函数的访问级别。不管 using
出现在哪,基类的私有构造函数在派生类中还是一个私有构造函数,protected
和 public
同样如此。一个 using
声明也不能指定 explicit
或者 constexpr
,继承的构造函数拥有与基类相同的属性。
含有默认实参的基类构造函数,其实参不会被继承。派生类将获得多个继承的构造函数,其中每个构造函数分别省略一个含有默认实参的形参。
如果派生类定义的构造函数与基类的构造函数具有相同的参数列表,则该构造函数将不会被继承。定义在派生类中的构造函数将替换继承而来的构造函数。默认、拷贝和移动构造函数不会被继承。这些构造函数按照正常的规则被合成。
析构函数
destructor 简述
析构函数释放对象使用的资源,并销毁对象的非 static
数据成员。析构函数的名字由波浪号接类名构成,它没有返回值,也不接受参数。对于一个给定类,只会有唯一一个析构函数。
析构函数有一个函数体和一个析构部分。与构造函数相反,在一个析构函数中,首先执行函数体,然后销毁成员,成员按初始化顺序的逆序销毁。通常,析构函数释放对象在生存期分配的所有资源。析构部分是隐式的,成员销毁时依赖于成员的类型,类类型需要执行成员自己的析构函数,内置类型什么也不需要做。注意:隐式销毁一个内置指针类型的成员不会 delete
它所指向的对象。 智能指针在析构阶段会自动销毁。
什么时候会调用 destructor
无论何时一个对象被销毁时,就会自动调用其析构函数:
- 变量在离开其作用域时被销毁。
- 当一个对象被销毁时,其成员被销毁。
- 容器(标准库或数组)被销毁时,其元素被销毁。
- 动态分配的对象,对指向它的指针应用
delete
时被销毁。 - 对于临时对象,当创建它的完整表达式结束时被销毁。
注意:当指向一个对象的引用或指针离开作用域时,析构函数不会执行。
合成 destructor
当一个类未定义自己的析构函数时,编译器为它定义一个合成析构函数。合成析构函数的函数体为空,在(空)析构函数体执行完毕后,成员会被自动销毁。
如果类的某个成员的析构函数是删除的或不可访问的,则类的合成析构函数和默认构造函数都被定义为删除的。
析构函数体自身并不直接销毁成员,成员是在析构函数体之后隐含的析构阶段中被销毁的。
OOP 中的 destructor
基类通常应该定义一个虚析构函数,使得我们可以动态分配继承体系中的对象。当 delete
一个动态分配的对象的指针时将执行析构函数。如果该指针指向继承体系中的某个类型,则有可能出现指针的静态类型与被删除对象的动态类型不符的情况。和其他函数一样,我们通过在基类中将析构函数定义成虚函数以确保执行正确的析构函数版本。如果基类的析构函数不是虚函数,则 delete
一个指向派生类的对象的基类指针将产生未定义的行为。
如果一个类定义了析构函数,即使它通过 =default
的形式使用了合成的版本,编译器也不会为这个类合成移动操作。
和派生类的构造函数及赋值运算符不同,派生类的析构函数只负责销毁由派生类自己分配的资源。派生类对象的基类部分是自动销毁的。对象的销毁顺序与其创建的顺序相反;派生类析构函数首先执行,然后是基类的析构函数,以此类推,沿着继承体系的反方向直至最后。
拷贝(copy)函数
copy constructor
copy constructor 的第一个参数是自身类类型的引用,且任何额外参数都有默认值。第一个参数必须是一个引用类型(在函数调用过程中,非引用类型的参数要进行拷贝初始化,参数不是引用类型,调用就会无效套娃下去。),几乎总是一个 const
的引用,拷贝构造函数在几种情况下会隐式地使用(比如向函数传递或从函数返回非引用,隐含了拷贝),通常不应该是 explicit
的。
合成的 copy constructor:
没有定义拷贝构造函数,即使定义了其他构造函数,编译器也会合成一个拷贝构造函数。合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中,编译器从给定对象中依次将每个非static
成员拷贝到正在创建的对象中。每个成员的类型决定了它如何拷贝:内置类型直接拷贝,类类型成员使用其拷贝构造函数来拷贝,逐元素拷贝数组类型的成员,数组元素如何拷贝由其元素类型决定(向前套娃)。
如果类的某个成员的拷贝构造函数是删除的或不可访问的,或者类的某个成员的析构函数是删除的或不可访问的,则类的合成拷贝构造函数被定义为删除的。拷贝初始化:
直接初始化实际上是编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数。拷贝初始化则求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换。 拷贝初始化通常使用拷贝构造函数来完成,有时也会使用 move constructor。拷贝初始化在我们用=
定义变量和以下情况时会发生:- 将一个对象作为实参传递给一个非引用类型的形参。
- 从一个返回类型为非引用类型的函数返回一个对象。
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员。
类类型对象还会对它们所分配的对象使用拷贝初始化。例如,标准库容器初始化或调用其 insert
和 push
成员时,容器会对其元素进行拷贝初始化,用 emplace
成员创建的元素都进行直接初始化。
在拷贝初始化过程中(例如使用 =
时),编译器可以(但不是必须)跳过拷贝/移动构造函数,直接(使用其他匹配的构造函数)创建对象。但在这个程序点上,拷贝/移动构造函数必须是存在且可访问的。
copy-assignment operator
前瞻
与类控制其对象如何初始化一样,类也可以控制其对象如何赋值。类可以定义自己的拷贝赋值运算符(copy-assignment operator)和移动赋值运算符(move-assignment operator)。
assignment operator 实际上是重载运算符,本质上是函数。重载运算符其名字由 operator
关键字后接表示要定义的运算符的符号组成。赋值运算符就是名为 operator=
的函数,有一个返回类型和一个参数列表(表示运算符的运算对象)。赋值运算符必须定义为成员函数,运算符左侧的运算对象绑定到隐式的 this
参数,右侧运算对象作为显式参数传递。为了与内置类型的赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用。标准库通常要求保存在容器中的类型要具有赋值运算符,且其返回值时左侧运算对象的引用。
简述
如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符。它将右侧运算对象的每个非 static
成员赋予左侧运算对象的对应成员,这一工作通过成员类型的拷贝赋值运算符来完成。对于数组,会逐个赋值数组元素。合成拷贝赋值运算符返回一个指向其左侧运算对象的引用。
如果类的某个成员的拷贝赋值运算符是删除的或不可访问的,或是有一个 const
的或引用成员,则类的合成拷贝赋值运算符被定义为删除的。
当编写行为像值的赋值运算符时,需注意:
- 如果将一个对象赋予它自身,赋值运算符必须能正确工作。
- 大多数赋值运算符组合了析构函数和构造函数的工作。
一个好的模式是,先将右侧运算对象拷贝到一个局部临时对象中。拷贝完成后,销毁左侧运算对象的现有成员(安全),之后将数据从临时对象拷贝到左侧运算对象的成员中。拷贝赋值运算符通常执行拷贝构造函数和析构函数中也要做的工作,应该将公共的工作放在 private
的工具函数中完成。赋值运算符必须处理自赋值!
移动(move)函数
前述
很多情况下会发生对象的拷贝,在其中的某些情况中,对象拷贝后就立即被销毁了,此时移动对象而非拷贝对象会大幅度提升性能。重新分配内存的过程中,从旧内存将元素拷贝到新内存是不必要的,更好的方式是移动元素。IO类或 unique_ptr
这样的类都包含不能被共享的资源,它们不能拷贝但可以移动。
右值引用
右值引用(rvalue reference)就是必须绑定到右值的引用。我们通过 &&
来获得右值引用,它只能绑定到一个将要销毁的对象。因此,我们可以自由地将一个右值引用的资源“移动”到另一个对象中。
左值和右值是表达式的属性,一个左值表达式表示的是一个对象的身份,而一个右值表达式表示的是对象的值。一个右值引用也是某个对象的另一个名字,我们不能将一个左值引用绑定到要求转换的表达式、字面值常量或是返回右值的表达式。右值引用有着完全相反的绑定特性:可以将一个右值引用绑定到这类表达式上,但不能将一个右值引用绑定到一个左值上。
赋值、下标、解引用和前置递增/递减运算符返回左值的表达式;返回非引用类型的函数,连同算术、关系、位以及后置递增/递减运算符都生成右值,可以将一个 const
的左值引用或者一个右值引用绑定到这类表达上。很明显,左值有持久的状态,右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。由于右值引用只能绑定到临时对象,所引用的对象将要被销毁,且该对象没有其他用户,使用右值引用的代码可以自由地接管(窃取)所引用的对象的资源。
特别地,变量表达式都是左值(变量是持久的,直至离开作用域才销毁)。我们不能将一个右值引用绑定到一个右值引用类型的变量上,即:
1
2
int &&rr1 = 42; // 正确
int &&rr2 = rr2; // 错误:表达式 rr1 是左值!
std::move()
虽然不能将一个右值引用直接绑定到一个左值上,但我们可以显式地将一个左值转换为对应的右值引用类型。通过调用一个名为 move
的标准库函数来获得绑定到左值上的右值引用,此函数定义在头文件 utility
中。move
告诉编译器:我们有一个左值,但我们希望像一个右值一样处理它。调用 move
就意味着承若:除了对其赋值或销毁它外,我们不再使用它。在调用 move
之后,我们不能对移后源对象的值做任何假设。
move constructor and move-assignment operator
简述
如果我们自己的类也同时支持移动和拷贝,那么也能从中受益。可为我们自己的类型定义移动构造函数和移动赋值运算符。它们从给定对象“窃取”资源而不是拷贝资源。
移动构造函数的第一个参数是该类类型的一个右值引用,任何额外的参数都必须有默认实参。除了完成资源移动,移动构造函数还必须确保移后源对象处于这样一个状态——销毁它是无害的。特别是,一旦资源完成移动,源对象必须不再指向被移动的资源——这些资源的所有权已经归属新创建的对象了。
由于移动操作“窃取”资源,它通常不分配任何资源,因此,移动操作通常不会抛出任何异常。除非标准库知道元素类型的移动操作不会抛出异常,否则,它就必须使用对应的拷贝操作而不是移动操作。我们通过使用 noexcept
显式地告诉标准库我们的移动操作不抛出任何异常,可以安全使用。noexcept
是我们承若一个函数不抛出异常的一种方法,在一个函数的参数列表后指定 noexcept
,且必须在类头文件的声明和定义中都指定 noexcept
。
移动赋值运算符执行与析构函数和移动构造函数相同的工作,且必须:
- 正确处理自赋值。
- 确保移后源对象为可析构的状态。
- 确保对象仍然有效,可以安全地为其赋予新值或可以安全使用而不依赖其当前值。
- 用户不能对其值进行任何假设。
合成的移动操作
只有当一个类没有定义任何自己版本的拷贝控制成员,且每个类的非 static
数据成员都可以移动时,编译器才会为它合成移动构造函数和移动赋值运算符,编译器可以移动内置类型的成员。如果类定义了一个移动操作,则该类的合成拷贝操作会被定义为删除的。如果一个类没有移动操作,通过正常的函数匹配,类会使用对应的拷贝操作来替代移动操作。
移动操作定义为删除的遵循的原则:
- 显式要求编译器生成
=default
,且编译器不能移动所有类的成员。 - 类成员定义了拷贝操作且未定义移动操作,或是有类成员未定义拷贝操作且编译器不能为其合成移动操作。
- 类成员的移动操作被定义为删除的或不可访问的。
- 类的析构函数被定义为删除的或不可访问的,则类的移动构造函数被定义为删除的。
- 有类成员时
const
的或是引用,则类的移动赋值运算符被定义为删除的。
匹配
编译器使用普通的函数匹配规则来确定使用哪个操作(move or copy):移动右值,拷贝左值。如果一个类没有移动操作,函数匹配保证该类型的对象会被拷贝,即使我们试图通过调用 move
来移动时也是如此。
移动迭代器
移动迭代器是标准库定义的一种移动迭代器适配器。它的解引用运算符返回一个右值引用。我们通过调用 make_move_iterator
函数将一个普通迭代器转换为一个移动迭代器。此函数接受一个迭代器参数,返回一个移动迭代器。原迭代器的所有其他操作在移动迭代器中都照常工作。我们可以将一对移动迭代器传递给算法,使其移动元素而不是拷贝元素,比如 uninitialized_copy
。标准库不保证哪些算法适用移动迭代器,移动一个对象可能销毁掉原对象,只有在确信算法在为一个元素赋值或将其传递给一个用户定义的函数后不再访问它时,才能将移动迭代器传递给算法。
成员函数的 copy 和 move
除了构造函数和赋值运算符,成员函数也可以提供拷贝和移动版本从中受益。拷贝版本接受一个指向 const
的左值引用(const T&
),移动版本接受一个指向非 const
的右值引用(T&&
)。定义了 push-back
的标准库容器就提供了这两个版本。 当我们希望从实参“窃取”数据时,通常传递一个右值引用,实参不能是 const
的。类似的,从一个对象进行拷贝的操作不应该改变该对象,实参是 const
的。
通常,我们在一个对象上调用成员函数,不管该对象是一个左值还是一个右值。为了向后兼容,允许向右值赋值,但是我们希望在自己的类中阻止这种做法,可以强制左侧运算对象(即 this 指向的对象)是一个左值。我们通过在参数列表后放置一个引用限定符来指出 this 的左值/右值属性(与定义 const
成员函数相同,必须同时出现在函数声明和定义中,引用限定符必须跟随在 const
限定符之后):对于 &
限定的函数,我们只能将它用于左值;对于 &&
限定的函数,只能用于右值。
引用限定符也可以区分重载版本,我们可以综合引用限定符和 const
来区分一个成员函数的重载版本。与 const
不同,如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符。
重载运算符
基本概念
当运算符作用于类类型的对象时,c++语言允许我们为其指定新的含义。重载的运算符是具有特殊名字的函数:它们的名字由关键字 operator
和其后要定义的运算符号共同组成。重载的运算符也包含返回类型、参数列表以及函数体。
一个重载的运算符必须是某个类的成员或者至少拥有一个类类型的运算对象。重载运算符的运算对象数量、结合律、优先级与对应的用于内置类型的运算符完全一致。当运算符被定义为成员时,类对象的隐式 this
指针绑定到第一个运算对象。赋值、下标、函数调用和箭头运算符必须作为类的成员。
通常情况下,我们可以直接调用一个重载的运算符函数(通过函数名字)。我们不应该重载逗号、取地址、逻辑与和逻辑或运算。因为使用重载的运算符本质上是一次函数调用,关于运算对象求值顺序的规则无法应用到重载的运算符上。与、或、逗号的运算对象求值顺序无法保留下来,&&
和 ||
的重载版本也无法保留内置运算符的短路求值属性。c++ 语言已经定义了逗号和取地址运算符用于类类型对象时的特殊含义,所以也不应该被重载。
明智使用运算符重载:
- 类执行IO操作,则定义移位运算符使其与内置IO保持一致。
- 类有检查相等性的操作,则定义
operator==
,也应定义operator!=
。 - 类包含一个内在的单序比较,则定义
operator<
,也应定义其他关系操作。若类同时包含==
,当且仅<
的定义和==
产生的结果一致时,才定义<
。 - 重载的运算符通常应与内置版本的返回类型兼容:逻辑和关系运算返回 bool,算术运算符返回一个类类型的值,赋值运算和复合赋值运算符返回左侧运算对象的一个引用。
- 只有当操作的含义对于用户来说清晰明了时才使用运算符,过分滥用运算符将使类变得难以理解。
- 如果类含有算术运算符或者位运算符,最好提供对应的复合运算符。
+=
运算符的行为应该与其内置版本一致,先执行+
,再执行=
。 - 将重载的运算符声明为类的成员函数还是声明为一个普通的非成员函数将会导致不同的结果:
- 改变对象状态或者与给定类型密切相关的运算符通常应该是成员,如递增、递减和解引用运算符。
- 具有对称性的运算符可能转换任意一端的运算对象,如算术、相等性、关系和位运算等,它们通常应该是普通的非成员函数。
- 运算符定义成成员函数时,它的左侧运算对象必须是运算符所属类的一个对象。
string
重载的 + 运算符是一个普通的非成员函数,所以"hi"+s
是可行的,等价于operator+("hi", s)
。
各重载运算符的注意事项
- 与
iostream
兼容的输入输出运算符必须是普通非成员函数,而不能是类的成员函数。否则,它的左侧运算对象将是我们的类的一个对象:
1
2
Sales_data data;
data << cout; // 如果 operator<< 是 Sales_data 的成员
输入输出运算符的左侧运算对象类型是 istream
或 ostream
,若它们是成员函数也应该是这两个类的成员,但是我们无法给标准库中的类添加任何成员。
- 输入运算符必须处理输入可能失败的情况,而输出运算符不需要。通常情况下,输入运算符只设置
failbit
,除此之外,设置eofbit
表示文件耗尽,设置badbit
表示流被破坏。 - 算术和关系运算符定义成非成员函数以允许对左侧或右侧的运算对象进行转换。这些运算符一般不需要改变对象的状态,所以形参都是常量的引用。一般情况下,如果类同时定义了算术运算符和相关的复合赋值运算符,使用复合赋值实现对应的算术运算符。
- 如果类在逻辑上有相等关系,则该类应该定义
operator=
,可以使得用户更容易使用标准库算法来处理这个类。相等运算符和不相等运算符中的一个应该把工作委托给另外一个。 - 关联容器和一些算法要用到小于运算符。
<
的定义在逻辑上一定要唯一可靠,且与==
产生的结果一致。 - 除了拷贝控制成员,类还可以定义其他赋值运算符以使用别的类型作为右侧运算对象。这种重载的赋值运算符也必须释放当前内存空间,再创建一片新空间,这个时候无需检查对象向自身的赋值。例如,形参类型为
initializer_list<T>
。 - 赋值运算符必须定义成类的成员,复合赋值运算符通常情况下也应该这样做。它们都应该返回左侧运算对象的引用。
- 表示容器的类通常可以通过元素在容器中的位置访问元素,这些类一般会定义下标运算符
operator[]
,通常会定义两个版本:一个返回普通引用,另一个是类的常量成员并返回常量引用。下标运算符必须是成员函数。 - 为了与内置版本保持一致,前置运算符应该返回递增或递减后对象的引用,后置运算符应该返回对象的原值(递增或递减之前的值),返回的形式是一个值而非引用。后置版本接受一个额外的(不被使用)
int
类型的形参,这个形参的唯一作用就是区分前置版本和后置版本。若想通过函数调用的方式调用后置版本,必须为它的整形参数传递一个值,编译器通过它才能知道使用后置版本。 - 在迭代器类及智能指针类中常常用到解引用运算符(
*
)和箭头访问运算符(->
)这两种成员访问运算符。箭头运算符必须是类的成员,解引用运算符通常也是类的成员,尽管并非必须如此。箭头运算符永远不能丢掉成员访问这个最基本的含义,当我们重载箭头时,改变的是从哪个对象当中获取成员,而箭头获取成员这一事实则永远不变。重载的箭头运算符必须返回类的指针或者自定义了箭头运算符的某个类的对象。当我们对一个变量调用->
时,若该变量是定义了operator->()
的类的一个对象,则使用的是该类类型对象的operator->()
的结果再一次调用结果类型的operator->()
,直到结果是一个指针(此时应用内置的箭头运算符)返回需要的内容或者发生了错误。对于我们定义的类,内置的箭头运算符无法解引用对象并从中获取指定的成员。我们重载的箭头运算符,通常应该完成该功能,通过借用解引用运算符得到对象的引用,再对其取地址返回对象的地址,然后内置的箭头运算符就能够通过该指针获取对象的成员了(这一步是在调用重载的箭头运算符时是自动调用的)。 - 如果类重载了函数调用运算符
operator()
,则该类的对象被称作“函数对象”,我们可以像使用函数一样使用该类的对象,它比普通函数更加灵活。函数调用运算符必须是成员函数,一个类可以定义多个不同版本的函数调用运算符,相互之间应该在参数数量或类型上有所区别。函数对象常常作为泛型算法的实参。更多关于调用运算符与函数对象。 - 类型转换运算符
函数匹配与重载运算符
重载的运算符也是重载的函数,通用的函数匹配规则同样适用于判断在给定的表达式中到底使用内置运算符还是重载的运算符(包括非成员版本和成员版本(左侧运算对象是类类型))。如果 a 是一种类类型,则表达式 a sym b
可能是:
1
2
a.operatorsym(b); // a 有一个 operatorsym 成员函数
operatorsym(a, b); // operatorsym 是一个普通函数
当我们调用一个命名的函数时,命名函数的语法形式对于成员函数和非成员函数来说是不相同的。当我们通过类类型的对象(或者指向该对象的引用及指针)进行函数调用时,只考虑该类的成员函数。而在表达式中使用重载的运算符时,无法判断正在使用的时成员函数还是非成员函数。
注意:如果我们对同一个类即提供了转换目标是算术类型的类型转换,也提供了重载的运算符,则将会遇到重载运算符与内置运算符的二义性问题。
虚函数
简述
虚函数,用于定义类型特定行为的成员函数。
在 c++ 语言中,基类将类型相关的函数与派生类不做改变直接继承的函数区分对待。基类希望它的派生类各自定义某些函数,使其适合自身,此时基类就将这些函数声明成虚函数。
动态绑定只作用于虚函数,并且需要通过指针或引用调用。动态绑定在运行时根据引用或指针所绑定的对象的实际类型来选择执行虚函数的某一个版本。当我们通过一个具有普通类型(非引用非指针)的表达式调用虚函数时,在编译时就会将调用的版本确定下来。
我们必须为每一个虚函数都提供定义,而不管它是否被用到了,这是因为连编译器也无法确定到底会使用哪个虚函数。
基类中的虚函数在派生类中隐含地也是一个虚函数。当派生类覆盖了某个虚函数时,该函数在基类中的形参和返回类型必须与派生类中的形参和返回类型严格匹配。例外是,当类的虚函数返回类型是类本身的指针或引用,且从派生类到基类的类型转换是可访问的,该规则无效(仅返回类型)。
final
和 override
说明符
如果派生类未覆盖其基类中的某个虚函数,则该虚函数会直接继承其在基类中的版本。派生类如果定义了一个函数与基类中的虚函数的名字相同但是形参列表不同,这任然合法,两个函数相互独立。在派生类中的成员函数参数列表后面添加关键字 override
显式地注明使用该成员函数覆盖它继承的虚函数。我们还能通过将某个函数指定为 final
,使得之后任何尝试覆盖该函数的操作都将引发错误。final
和 override
说明符出现在形参列表(包括任何const
关键字或引用限定符)以及尾置返回类型之后。
虚函数与默认实参
虚函数可以拥有默认实参,使用默认实参的虚函数调用的实参值由该次调用的静态类型决定。使用基类的引用和指针调用函数,则使用基类中定义的默认实参。如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致。
回避虚函数的机制
使用作用域运算符可以对虚函数的调用不进行动态绑定,强迫其执行虚函数的某个特定版本。这样的调用会在编译时完成解析。通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数的机制。
纯虚函数与抽象基类
通过在虚函数的声明语句的分号之前书写 =0
将该虚函数声明为纯虚函数。=0
只能出现在类内部的虚函数声明语句处。我们可以为纯虚函数提供定义,函数体必须定义在类的外部。
含有(或者未经覆盖直接继承)纯虚函数的类是抽象基类(abstract base class)。抽象基类负责定义接口,而后续的其他类可以覆盖该接口。不能直接创建一个抽象基类的对象。
C++的拷贝控制总结
前述
当定义一个类时,我们显式或隐式地指定此类型的对象拷贝、移动、赋值和销毁时做什么。通过五种特殊的成员函数来控制这些操作:拷贝构造函数(copy constructor)、拷贝赋值运算符(copy-assignment operator)、移动构造函数(move constructor)、移动赋值运算符(move-assignment operator)和析构函数(destructor)。拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么。拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么。析构函数定义了当此类型对象销毁时做什么。这些操作被称为拷贝控制操作(copy control)。
如果一个类未声明这些操作,编译器会自动为其生成。如果这些操作未定义成删除的,它们会逐成员初始化、移动、赋值或销毁对象:合成的操作依次处理每个非 static
数据成员,根据成员类型确定如何移动、拷贝、赋值或销毁它。
当一个类没有定义这些拷贝控制成员,编译器会自动为它定义缺失的操作。但对一些类来说,依赖这些操作的默认定义会导致灾难。我们需要认识到什么时候会需要定义这些操作。
=default
和 =delete
通过将成员定义为 =default
来显式地要求编译器生成合成的constructor、copy-constructor、copy-assignment operator、destructor。
对某些类来说,以上这些操作没有合理意义时,可采用某种机制阻止拷贝或赋值。通过将 copy-constructor 以及 copy-assignment operator 定义为删除的函数来阻止拷贝。在函数的参数列表后面加上 =delete
来指出它被定义为删除的。=delete
告诉编译器(以及代码的读者),我们不希望定义这些成员。=delete
必须出现在函数的第一次声明时(=default
可以出现在类外,直到编译器生成代码时才需要),以便编译器禁止试图使用它的操作。与 =default
不同,可以对任何函数指定 =delete
,但是我们不应该删除析构函数。对于析构函数已删除的类型,不能定义该类型的变量或释放(意味着可动态分配)指向该类型动态分配对象的指针。
合成的 copy control 可能是删除的
值得注意的是,如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。当不可能拷贝、赋值或销毁类的成员时,类的合成拷贝控制成员就被定义为删除的。还有些显而易见的:
- 一个成员有删除的或不可访问的析构函数会导致合成的默认和拷贝构造函数被定义为删除的;
- 对于具有引用成员或无法默认构造的
const
成员的类,编译器不会为其合成默认构造函数; - 如果一个类有
const
成员,则它不能使用合成的拷贝赋值运算符;对于有引用成员的类,合成拷贝赋值运算符被定义为删除的,因为赋值改变的是引用指向的对象的值,而不是引用本身。
整体性
所有五个拷贝控制成员应该看做一个整体。分配了内存或其他资源的类需要拷贝资源,在特殊情况的下移动资源可以降低开销,销毁资源需要析构函数处理。
分配了内存或其他资源的类几乎总是需要定义拷贝控制成员来管理分配的资源。合成的析构函数不会 delete
一个指针数据成员,合成的拷贝构造函数和拷贝赋值运算符简单拷贝指针成员,多个对象可能共享相同的内存。因此,如果一个类需要自定义析构函数,几乎可以肯定它也需要自定义拷贝赋值运算符和拷贝构造函数。需要拷贝构造操作的类也需要拷贝赋值操作,反之亦然。
其他
- 拷贝并交换(copy and swap):涉及赋值运算符的技术,首先拷贝右侧运算对象(发生在函数传参过程中的拷贝——拷贝构造新对象——副本),然后调用 swap 来交换(可正常移动,副本为临时对象)副本和左侧运算对象。
OOP 中的 copy control
合成拷贝控制与继承
基类或派生类的合成拷贝控制成员的行为与其他合成的构造函数、赋值运算符或析构函数类似:它们对类本身的成员依次进行初始化、赋值或销毁的操作。合成的成员还负责使用直接基类中对应的操作对一个对象的直接基类部分进行初始化、赋值或销毁的操作。无论基类的对应成员是合成的版本还是自定义的版本,只要可访问且不是一个被删除的函数。
一些定义基类的方式可能导致有的派生类成员成为被删除的函数:
- 如果基类中的默认构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是被删除的或者不可访问,则派生类中对应的成员将是被删除的,原因是编译器不能使用基类成员来执行派生类对象基类部分的构造、赋值或销毁操作。
- 如果基类中有一个不可访问或删除掉的析构函数,则派生类中合成的默认和拷贝构造函数是被删除的,因为编译器无法销毁派生类对象的基类部分。
- 如果基类中的析构函数或移动操作是被删除的或不可访问,则派生类的移动构造函数和对应的移动操作将是被删除的。
大多数基类都会定义一个虚析构函数,因此默认情况下,基类通常不含有合成的移动操作,则在它的派生类中也没有合成的移动操作。
基类缺少移动操作会阻止派生类拥有自己的合成移动操作,当我们确实需要移动操作是应该首先在基类中定义。
派生类中拷贝控制成员
当派生类定义了拷贝或移动操作时,该操作负责拷贝或移动包括基类部分成员在内的整个对象。析构函数只负责销毁派生类自己分配的资源。我们通常使用对应的基类构造函数和赋值运算符为派生类对象的基类部分初始化和赋值。
如果构造函数或析构函数调用了某个虚函数,则我们应该执行与构造函数或析构函数所属类型相对应的虚函数版本。执行构造函数与析构函数,对象处于未完成的状态,调用虚函数应执行正在构造或析构的那个基类部分或派生类部分的虚函数版本。
类型转换
只接受单独一个实参的非显式构造函数定义了从实参类型到类类型的隐式类型转换;而非显式的类型转换运算符则定义了从类类型到其他类型的隐式转换。转换构造函数和类型转换运算符共同定义了类类型转换(class-type conversions),也被称作用户定义的类型转换(user-defined conversions)。
类型转换运算符
类型转换运算符(conversion operator)是类的一种特殊成员函数,它负责将一个类类型的值转换成其他类型。类型转换函数的一般形式如下所示:
1
operator type() const { }
type
是某种类型(除了 void
之外),该类型能作为函数的返回类型(不允许数组或函数类型,但允许指针(数组指针及函数指针)或者引用类型)。类型转换运算符既没有显式的返回类型,也没有形参,而且必须定义成类的成员函数。类型转换运算符通常不应该改变待转换对象的内容,一般被定义为 const
成员。
尽管编译器一次只能执行一个用户定义的类型转换,但是隐式的用户定义类型转换可以置于一个标准(内置)类型转换之前或之后,并与其一起使用。类型转换运算符是隐式执行的,所以无法给这些函数传递实参。尽管类型转换函数不负责指定返回类型,但实际上每个类型转换函数都会返回一个对应类型(type
)的值。
如果在类类型和转换类型之间不存在明显的映射关系,则这样的类型转换可能具有误导性。此时,定义一个或多个普通的成员函数以从各种不同形式中提取所需的信息是一种更好的方案。例外情况是,定义向 bool
的类型转换是比较普遍的。
类型转换运算符可能产生意外结果。为了防止意外情况,可以使用显式的类型转换运算符。
1
explicit operator type() const { }
和显式的构造函数一样,编译器通常也不会将一个显式的类型转换运算符用于隐式类型转换。此时,必须通过显式的强制类型转换才可以执行类型转换。但是如果表达式被用作条件,则编译器会将显式的强制类型转换自动应用于它,即显式的类型转换将被隐式地执行。向 bool
的类型转换通常用在条件部分,因此 operator bool
一般定义成 explicit
的。
避免有二义性的类型转换
如果类中包含一个或多个类型转换,则必须确保在类类型和目标类型之间只存在唯一一种转换方式。否则的话,我们编写的代码将很可能会具有二义性。
- 两个类定义了相同的类型转换。如 A 类定义了一个接受 B 类对象的转换构造函数,同时 B 类定义了一个转换目标是 A 类的类型转换运算符,它们提供了相同的类型转换。使用强制类型转换也无法解决此二义性问题,因为强制类型转换本身也面临二义性。
- 类定义了多个转换规则,而这些转换涉及的类型本身可以通过其他类型转换联系在一起。例如,当类当中定义了转换目标都是算术类型的类型转换运算符时,编译器很多时候无法确定哪种算术类型的转换更好,因此相关调用会产生二义性。
通常情况下,不要为类定义相同的类型转换,也不要在类中定义两个及以上转换源或转换目标是算术类型的转换。
当我们使用两个用户定义的类型转换时,如果转换函数之前或之后存在标准类型转换,则标准类型转换将决定最佳匹配到底是哪个。因此,算术类型容易在此产生二义性。
设计类的重载运算符、转换构造函数及类型转换函数必须加倍小心,尤其是当类同时定义了类型转换运算符及重载运算符( +、-、… )时,以下是一些经验准则:
- 不要令两个类执行相同的类型转换。
- 避免转换目标是内置算术类型的类型转换。当定义了一个转换成算术类型的类型转换时:
- 不要再定义接受算术类型的重载运算符。替代方法:使用类型转换操作转换你的类型的对象,然后再使用内置的运算符完成该重载运算符的功能。
- 让标准类型转换完成向其他算术类型转换的工作,而不是定义转换到其他多种算术类型的转换。
- 总之,除了显式地向
bool
类型的转换之外,我们应该尽量避免定义类型转换函数,并尽可能地限制那些“显然正确”的非显式构造函数。
当我们调用重载的函数时,从多个类型转换中进行选择(形参与实参的匹配,实参具有多种类型转换)将变得更加复杂。如果两个或多个类型转换都提供了同一种可行匹配,则这些类型转换一样好。在这个过程中,不会考虑任何可能出现的标准类型转换的级别,只要当重载函数能通过同一个类型转换函数得到匹配时,我们才会考虑其中出现的标准类型转换。即,当调用重载函数所请求的(参数)用户定义的类型转换不止一个且彼此不同,该调用具有二义性。即使其中一个调用需要额外的标准类型转换而另一个调用能精确匹配,编译器也会将该调用标识为错误。
注意:如果我们对同一个类即提供了转换目标是算术类型的类型转换,也提供了重载的运算符,则将会遇到重载运算符与内置运算符的二义性问题。
There’s a screenshot from Mooophy/Cpp-Primer
OOP 中的类类型转换
在派生类对象中含有与其基类对应的组成部分,所以我们能把派生类的对象当成基类对象来使用,而且我们也能将基类的指针(包括智能指针类)或引用绑定到派生类对象中的基类部分。这种转换通常称为派生类到基类(derived-to-base)的类型转换。和其他类型转换一样,编译器会隐式地执行派生类到基类的转换。
不存在从基类向派生类的隐式类型转换,即使一个基类指针或引用绑定在一个派生类对象上,我们也不能执行从基类向派生类的转换。这是因为编译器无法确定某个特定的转换在运行时是否安全,编译器只能通过检查指针或引用的静态类型来推断该转换是否合法。如果我们已知某个基类向派生类的转换是安全的,可以使用 static_cast
来强制覆盖掉编译器的检查工作。
派生类向基类的自动转换只对指针或引用有效,在派生类类型和基类类型之间不存在这样的转换。注意,当我们初始化或赋值一个类类型的对象时,实际上是在调用某个函数。这些成员(构造函数或赋值运算符)通常都包含一个参数,其类型是类类型的 const
版本的引用。这些成员接受引用作为参数,所以派生类向基类的转换允许我们给基类的拷贝/移动操作传递一个派生类的对象。这些操作不是虚函数,当我们给基类的构造函数传递一个派生类对象时,实际运行的构造函数是基类中定义的那个,显然该构造函数只能处理基类自己的成员。类似的,赋值运算符也是一样。在这些过程中,派生类对象的派生类部分被忽略掉了。
C++的名字查找
普通的名字查找
名字查找(name lookup):寻找与所用名字最匹配的的声明的过程。
- 首先,在名字所在块中寻找,只考虑在名字的使用之前出现的声明。
- 如果未找到,继续查找外层作用域。
- 如果最终未找到匹配的声明,则程序报错。
类的名字查找
声明之后定义之前的类是一个不完全类型(incomplete type),可以定义指向这种类型的指针或引用,也可以声明(但是不能定义)以不完全类型作为参数或者返回类型的函数。
一旦一个类的名字出现后就被认为时声明过了,类的定义分两步处理:
- 首先,编译成员的声明。
- 直到类全部可见后才编译函数体。
编译器处理完类中的全部声明后才会处理成员函数的定义,因此这种两阶段的处理方式只适用于成员函数(体)中使用的名字。声明中使用的名字(返回类型、参数列表)都必须在使用前确保可见。如果某个成员的声明使用了类中尚未出现的名字,则编译器将会在定义该类的作用域中继续查找。
在类中不能重新定义(覆盖)外层作用域中某个代表一种类型的名字,重新定义类型名字是一种错误行为,编译器并不为此负责。类型名的定义通常出现在类的开始处,确保所有使用该类型的成员都出现在类型名的定义之后。
成员函数中的名字查找:
- 首先,在成员函数内(名字前)查找该名字的声明。
- 上一步未找到,则在类内继续查找,这时类的所有成员都可以被考虑。
- 上一步(类内)未找到名字的声明,在成员函数定义之前的作用域内继续查找。(当成员定义在类的外部时,类定义之前的全局作用域中的声明和在成员函数定义之前的全局作用域中的声明都要考虑,即包括了成员函数出现之前的全局作用域)
友元的名字查找
当把类 A 的成员函数 a 声明为类 B 的一个友元时,类 A 的成员函数 a 必须在类 B 之前被声明,却不能定义它。因为在成员函数 a 内要使用类 B 的成员,所以必须先声明定义类B(包括对成员函数 a 的友元声明)。接下来才能定义成员函数 a,此时它才能使用类 B 的成员。示例代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
#include <vector>
class B;
class A
{
std::vector<B> elements;
public:
void a(int);
};
extern B b; // it's ok.
class B
{
friend void A::a(int);
int element = 0;
static int e;
};
int B::e = 1;
void A::a(int i)
{
while(elements.size() <= i)
elements.push_back(B());
std::cout << elements[i].element << std::endl;
}
int main()
{
A x;
x.a(2);
}
我们隐式地假定第一次出现在一个友元声明中的名字在当前作用域中是可见的,但该名字并不一定真的声明在当前作用域中。友元声明的作用是影响访问权限,并非普通意义上的声明,必须在类的外部提供相应的声明从而使得函数(被声明为友元的)可见。
OOP 中的名字查找
当类存在继承关系时,派生类的作用域嵌套在其基类的作用域之内。如果一个名字在派生类的作用域内无法正确解析,则编译器将继续在外层的基类作用域中寻找该名字的定义。
一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的。即使静态类型与动态类型可能不一致(当使用基类的引用或指针时会发生这种情况),但是我们能使用的哪些成员仍然是由静态类型决定的。比如,使用基类指针的时候,对其成员的搜索将从该基类开始,而其派生类的成员将不会在搜索范围中。
和其他作用域一样,派生类也能重新定义在其基类或间接基类中的名字,此时定义在内层作用域(即派生类)的名字将隐藏定义在外层作用域(即基类)的名字。我们可以通过作用域运算符来使用一个被隐藏的基类成员。除了覆盖继承而来的虚函数之外,派生类最好不要重用其他定义在基类中的名字。
假定我们调用 p->mem()
(或者 obj.mem()
),将依次执行以下4个步骤:
- 首先确定
p
(或obj
)的静态类型。 - 在
p
(或obj
)的静态类型对应的类中查找mem
。如果找不到,则依次在直接基类中不断查找直至到达继承链的顶端。如果找遍了该类及其基类仍然找不到,则编译器将报错。 - 一旦找到了
mem
,就进行常规的类型检查以确认对于当前找到的mem
,本次调用是否合法。 - 假设调用合法,则编译器根据调用的是否是虚函数而产生不同的代码:
- 如果
mem
是虚函数且我们是通过引用或指针进行的调用,则编译器产生的代码将在运行时确定到底运行虚函数的哪个版本,依据是对象的动态类型。 - 反之,如果
mem
不是虚函数或者我们是通过对象(而非引用或指针)进行的调用,则编译器将产生一个常规函数调用。
- 如果
记住:名字查找先于类型检查。声明在内层作用域的函数并不会重载声明在外层作用域的函数。因此,定义在派生类中的函数也不会重载其基类中的成员。如果派生类(即内层作用域)的成员与基类(即外层作用域)的某个成员同名,则派生类将在其作用域内隐藏该基类成员。即使派生类成员和基类成员的形参列表不一致,基类成员也仍然会被隐藏。
假如基类与派生类的虚函数接受的实参不同,则我们就无法通过基类的引用或指针调用派生类的虚函数了。因为名字查找先与类型检查,找到了该名字后,再进行类型检查,若不是虚函数且参数不匹配,编译器将报错,基类(即外层作用域)的名字根本不会被找到。
所以说,要想基类中的某个名字(成员函数)可见,我们要么覆盖该名字的所有版本,要么一个也不覆盖。我们可以为重载的成员提供一条 using
声明语句,它指定一个名字而不指定形参列表,所以一条基类成员函数的 using
声明语句就可以把该函数的所有重载实例添加到派生类作用域中。此时,派生类只需要定义其特有的函数就可以了,而无需为继承而来的其他函数重新定义。对派生类没有重新定义的重载版本的访问实际上是对 using
声明点的访问。
可调用对象
简述
C++语言中有几种可调用对象:函数、函数指针、lambda 表达式、bind
创建的对象以及重载了函数调用运算符的类。
可调用对象也有类型,例如:每个 lambda 有它自己唯一的(未命名)的类类型;函数及函数指针的类型则由其返回类型和实参类型决定,等等。
两个不同类型的可调用对象却可能共享同一种调用形式(call signature)。调用形式指明了调用返回的类型以及传递给调用的实参类型。一种调用形式对应一个函数类型,例如:
1
int(int, int);
标准库 function 类型
有时候,我们希望几个具有同一种调用形式的可调用对象能被看成具有相同的类型。例如,在实现函数表时,不同类型的可调用对象需要当做同一种类型处理(通常把它们存储在 map
中)。
使用一个名为 function
的标准库类型可以解决将不同类型的可调用对象做同一种类型处理的问题。function
是一个模板,需提供的模板信息为能够表示的对象的调用形式。它定义在头文件 functional
中,下面是它的操作:
operation | explanation |
---|---|
function<T> f; | f 是一个用来存储可调用对象的空 function ,这些可调用对象的调用形式应该与函数类型 T 相同(即 T 是 retType(args) )。 |
function<T> f(nullptr); | 显式地构造一个空 function 。 |
function<T> f(obj); | 在 f 中存储可调用对象 obj 的副本。 |
f | 将 f 作为条件:当 f 含有一个可调用对象时为真;否则为假 |
f(args) | 调用 f 中的对象,参数是 args 。 |
定义为 function<T> 的成员类型 | |
result_type | 该 function 类型的可调用对象返回的类型 |
argument_type | 当 T 有一个或两个实参时定义的类型。如果 T 只有一个实参,则argument_type 是该类型的同义词; |
first_argument_type 、second_agument_type | 如果 T 有两个实参,则 first_argument_type 和 second_argument_type 分别代表两个实参的类型。 |
1
function<int(int, int)>
这里声明了一个 function
类型,它可以表示接受两个 int
、返回一个 int
的可调用对象。
注意:不能将重载函数的名字存入 function
类型的对象中,因为可能会产生二义性。解决途径是存储函数指针而非函数名字或使用 lambda 。
标准库定义的函数对象
标准库定义了一组表示算术运算符、关系运算符和逻辑运算符的类,每个类分别定义了一个执行命名操作的调用运算符。这些类都被定义成模板的形式,我们可以为其指定具体的应用类型,这里的类型即调用运算符的形参类型。它们都定义在头文件 functional
中:
算术 | 关系 | 逻辑 |
---|---|---|
plus<Type> ( + ) | equal_to<Type> | logical_and<Type> |
minus<Type> ( - ) | not_equal_to<Type> | logical_or<Type> |
multiplies<Type> ( * ) | greater<Type> | logical_not<Type> |
divides<Type> ( / ) | greater_equal<Type> | |
modulus<Type> ( % ) | less<Type> | |
negate<Type> ( - ) | less_equal<Type> |
表示运算符的函数对象类常用来替换算法中的默认运算符。例如,传递给 sort
函数一个 greater
类型的对象,sort
将执行待排序类型的大于运算,而不再是默认的 <
运算。
特别地,标准库规定其函数对象对于指针同样适用。比较两个指针将产生未定义的行为,标准库函数对象却不会。关联容器使用 less<key_type>
对元素排序,因此我们可以定义一个指针的 set
或 map
而无需直接声明 less
。