C++11常用特性介绍
- 函数模板类型推导
- auto类型推导
- decltype类型推导
- 优先使用delete禁用函数
- 重写函数加override声明
- 尽量使用限域枚举
- 尽量使用constexpr
- 谨慎使用{}初始化
- 优先使用nullptr而非NULL和0
- 优先使用using别名声明而不是typedef
- 智能指针
函数模板类型推导
假设有以下函数模板:
template<typename T>
void f(ParamType param);
f(expr);
函数模板T的类型推导不仅取决于expr的类型,也取决于ParamType的类型。这里有三种情况:
- ParamType既不是指针也不是引用。
- ParamType是一个指针或引用,但不是通用引用。
- ParamType一个通用引用。
ParamType既不是指针也不是引用
当ParamType
既不是指针也不是引用时,通过传值(pass-by-value)的方式处理:
- 忽略
expr
的引用性(reference-ness)、const
、volatile
。 - 如果
expr
为指向常量对象的指针,保留常量对象的const
属性。
template<typename T>
void f(T param); //以传值的方式处理param
int x = 27; //x是int
const int cx = x; //cx是const int
const int& rx = x; //rx是指向作为const int的x的引用
const char* const ptr = //ptr是一个常量指针,指向常量对象
"Fun with pointers";
f(x); //T和param的类型都是int
f(cx); //T和param的类型都是int
f(rx); //T和param的类型都是int
f(ptr); //T和param的类型都是const char*
ParamType是引用或指针,但不是通用引用
- 如果
expr
的类型是一个引用,忽略引用部分 - 然后
expr
的类型与ParamType
进行模式匹配来决定T
template<typename T>
void f(T& param); //param是一个引用
int x = 27; //x是int
const int cx = x; //cx是const int
const int& rx = x; //rx是指向作为const int的x的引用
f(x); //T是int,param的类型是int&
f(cx); //T是const int,param的类型是const int&
f(rx); //T是const int,param的类型是const int&
如果将f
的形参类型T&
改为const T&
,const
不再被推导为T
的一部分:
template<typename T>
void f(const T& param); //param现在是reference-to-const
int x = 27; //x是int
const int cx = x; //cx是const int
const int& rx = x; //rx是指向作为const int的x的引用
f(x); //T是int,param的类型是const int&
f(cx); //T是int,param的类型是const int&
f(rx); //T是int,param的类型是const int&
如果param
是一个指针(或者指向const
的指针)而不是引用,情况不变:
template<typename T>
void f(T* param); //param现在是指针
int x = 27; //x是int
const int *px = &x; //px是指向作为const int的x的指针
f(&x); //T是int,param的类型是int*
f(px); //T是const int,param的类型是const int*
ParamType是通用引用
- 如果
expr
是左值,T
和ParamType
都会被推导为左值引用。这非常不寻常,第一,这是模板类型推导中唯一一种T
被推导为引用的情况。第二,虽然ParamType
被声明为右值引用类型,但是最后推导的结果是左值引用。 - 如果
expr
是右值,就使用正常的(也就是ParamType是引用或指针,但不是通用引用)推导规则
template<typename T>
void f(T&& param); //param现在是一个通用引用类型
int x = 27; //x是int
const int cx = x; //cx是const int
const int& rx = x; //rx是指向作为const int的x的引用
f(x); //x是左值,所以T是int&,
//param类型也是int&
f(cx); //cx是左值,所以T是const int&,
//param类型也是const int&
f(rx); //rx是左值,所以T是const int&,
//param类型也是const int&
f(27); //27是右值,所以T是int,
//param类型就是int&&
数组实参和函数实参
在C++中数组类型和函数类型会退化为一个函数指针:
const char name[] = "J. P. Briggs"; //name的类型是const char[13]
template<typename T>
void f1(T param); //传值形参的模板
f1(name); //T和param类型都为const char*
template<typename T>
void f2(T& param); //传引用形参的模板
f2(name); //T为const char[13],param为const char (&)[13]
void someFunc(int, double); //someFunc是一个函数,
//类型是void(int, double)
template<typename T>
void f1(T param); //传值给f1
template<typename T>
void f2(T& param); //传引用给f2
f1(someFunc); //param被推导为指向函数的指针,
//类型是void(*)(int, double)
f2(someFunc); //param被推导为指向函数的引用,
//类型是void(&)(int, double)
结论
- 在模板类型推导时,有引用的实参会被视为无引用,他们的引用会被忽略
- 对于通用引用的推导,左值实参会被特殊对待
- 对于传值类型推导,const和/或volatile实参会被认为是non-const的和non-volatile的
- 在模板类型推导时,数组名或者函数名实参会退化为指针,除非它们被用于初始化引用
auto类型推导
有如下函数模板类型:
template<typename T>
void f(ParmaType param);
f(expr); //使用一些表达式调用f
在f
的调用中,编译器使用expr
推导T
和ParamType
的类型。当一个变量使用auto
进行声明时,auto
扮演了模板中T
的角色,变量的类型说明符扮演了ParamType
的角色。考虑这个例子:
auto x = 27; // x的类型说明符是`auto`
const auto cx = x; // cx的类型说明符是const auto
const auto & rx=cx; // rx的类型说明符是const auto&
编译器的行为看起来就像是认为这里每个声明都有一个模板,然后使用合适的初始化表达式进行调用:
template<typename T> //概念化的模板用来推导x的类型
void func_for_x(T param);
func_for_x(27); //概念化调用:
//param的推导类型是x的类型
template<typename T> //概念化的模板用来推导cx的类型
void func_for_cx(const T param);
func_for_cx(x); //概念化调用:
//param的推导类型是cx的类型
template<typename T> //概念化的模板用来推导rx的类型
void func_for_rx(const T & param);
func_for_rx(x); //概念化调用:
//param的推导类型是rx的类型
auto的类型说明符可以分为3中情景:
- 情景一:类型说明符是一个指针或引用但不是通用引用
- 情景二:类型说明符一个通用引用
- 情景三:类型说明符既不是指针也不是引用
我们早已看过情景一和情景三的例子:
auto x = 27; //情景三(x既不是指针也不是引用)
const auto cx = x; //情景三(cx也一样)
const auto & rx=cx; //情景一(rx是非通用引用)
情景二像你期待的一样运作:
auto&& uref1 = x; //x是int左值,
//所以uref1类型为int&
auto&& uref2 = cx; //cx是const int左值,
//所以uref2类型为const int&
auto&& uref3 = 27; //27是int右值,
//所以uref3类型为int&&
在函数模板类型推导中,数组和函数名会退化为指针,同样的情况也适用于auto
类型推导:
const char name[] = //name的类型是const char[13]
"R. N. Briggs";
auto arr1 = name; //arr1的类型是const char*
auto& arr2 = name; //arr2的类型是const char (&)[13]
void someFunc(int, double); //someFunc是一个函数,
//类型为void(int, double)
auto func1 = someFunc; //func1的类型是void (*)(int, double)
auto& func2 = someFunc; //func2的类型是void (&)(int, double)
就像你看到的那样,auto
类型推导和模板类型推导几乎一样的工作,它们就像一个硬币的两面。
auto
类型推导有一点和模板类型推导的工作方式不同。如果你想声明一个带有初始值27的int
,:
int x1 = 27;
int x2(27);
C++11由于也添加了用于支持统一初始化(uniform initialization)的语法:
int x3 = { 27 };
int x4{ 27 };
总之,这四种不同的语法只会产生一个相同的结果:变量类型为int
值为27
如果把上面声明中的int
替换为auto
,我们会得到这样的代码:
auto x1 = 27;
auto x2(27);
auto x3 = { 27 };
auto x4{ 27 };
这些声明都能通过编译,但是他们不像替换之前那样有相同的意义。前面两个语句确实声明了一个类型为int
值为27的变量,但是后面两个声明了一个存储一个元素27的 std::initializer_list<int>
类型的变量。
auto x1 = 27; //类型是int,值是27
auto x2(27); //同上
auto x3 = { 27 }; //类型是std::initializer_list<int>,
//值是{ 27 }
auto x4{ 27 }; //同上
结论
auto
类型推导通常和模板类型推导相同,但是auto
类型推导假定花括号初始化代表std::initializer_list
,而模板类型推导不这样做auto
变量必须初始化,通常它可以避免一些移植性和效率性的问题,也使得重构更方便,还能让你少打几个字。- 不可见的代理类可能会使
auto
从表达式中推导出“错误的”类型 - 显式类型初始器惯用法强制
auto
推导出你想要的结果
decltype类型推导
相比模板类型推导和auto
类型推导,decltype
只是简单的返回名字或者表达式的类型:
const int i = 0; //decltype(i)是const int
bool f(const Widget& w); //decltype(w)是const Widget&
//decltype(f)是bool(const Widget&)
struct Point{
int x,y; //decltype(Point::x)是int
}; //decltype(Point::y)是int
Widget w; //decltype(w)是Widget
if (f(w))… //decltype(f(w))是bool
template<typename T> //std::vector的简化版本
class vector{
public:
…
T& operator[](std::size_t index);
…
};
vector<int> v; //decltype(v)是vector<int>
…
if (v[0] == 0)…
在C++11中,decltype最主要的用途就是用于声明函数模板,而这个函数返回类型依赖于形参类型。
template<typename Container, typename Index> //可以工作,
auto authAndAccess(Container& c, Index i) //但是需要改良
->decltype(c[i])
{
authenticateUser();
return c[i];
}
结论
decltype
总是不加修改的产生变量或者表达式的类型。- 对于
T
类型的不是单纯的变量名的左值表达式,decltype
总是产出T
的引用即T&
。 - C++14支持
decltype(auto)
,就像auto
一样,推导出类型,但是它使用decltype
的规则进行推导。
3. 优先使用delete禁用函数
优点
1. 编译时就能检查到错误
2. 使用范围不局限于类的成员函数
3. 禁止一些模板的实例化
编译时就能检查到错误
在C++98中防止调用这些函数的方法是将它们声明为私有(private
)成员函数并且不定义。将它们声明为私有成员可以防止客户端调用这些函数。故意不定义它们意味着假如还是有代码用它们(比如成员函数或者类的友元friend
),就会在链接时引发缺少函数定义(missing function definitions)错误。
在C++98中是这样声明的(包括注释):
template <class charT, class traits = char_traits<charT> >
class basic_ios : public ios_base {
public:
…
private:
basic_ios(const basic_ios& ); // not defined
basic_ios& operator=(const basic_ios&); // not defined
};
在C++11中有一种更好的方式达到相同目的:用“= delete
”将拷贝构造函数和拷贝赋值运算符标记为deleted函数。上面相同的代码在C++11中是这样声明的:
template <class charT, class traits = char_traits<charT> >
class basic_ios : public ios_base {
public:
…
basic_ios(const basic_ios& ) = delete;
basic_ios& operator=(const basic_ios&) = delete;
…
};
deleted函数不能以任何方式被调用,即使你在成员函数或者友元函数里面调用deleted函数也不能通过编译。这是较之C++98行为的一个改进,C++98中不正确的使用这些函数在链接时才被诊断出来。
注意,通常deleted函数被声明为public
而不是private
。这也是有原因的。当客户端代码试图调用成员函数,C++会在检查deleted状态前检查它的访问性。当客户端代码调用一个私有的deleted函数,一些编译器只会给出该函数是private
的错误,即使函数的访问性不影响它是否能被使用。所以值得牢记,如果要将老代码的“私有且未定义”函数替换为deleted函数时请一并修改它的访问性为public
,这样可以让编译器产生更好的错误信息。
禁止一些模板的实例化
假如你要求一个模板仅支持原生指针:
template<typename T>
void processPointer(T* ptr);
在指针的世界里有两种特殊情况。一是void*
指针,因为没办法对它们进行解引用,或者加加减减等。另一种指针是char*
,因为它们通常代表C风格的字符串,而不是正常意义下指向单个字符的指针。这两种情况要特殊处理,在processPointer
模板里面,我们假设正确的函数应该拒绝这些类型。也即是说,processPointer
不能被void*
和char*
调用。
要想确保这个很容易,使用delete
标注模板实例:
template<>
void processPointer<void>(void*) = delete;
template<>
void processPointer<char>(char*) = delete;
现在如果使用void*
和char*
调用processPointer
就是无效的,按常理说const void*
和const void*
也应该无效,所以这些实例也应该标注delete
:
template<>
void processPointer<const void>(const void*) = delete;
template<>
void processPointer<const char>(const char*) = delete;
如果你想做得更彻底一些,你还要删除const volatile void*
和const volatile char*
重载版本,另外还需要一并删除其他标准字符类型的重载版本:std::wchar_t
,std::char16_t
和std::char32_t
。
有趣的是,如果的类里面有一个函数模板,你可能想用private
(经典的C++98惯例)来禁止这些函数模板实例化,但是不能这样做,因为不能给特化的成员模板函数指定一个不同于主函数模板的访问级别。如果processPointer
是类Widget
里面的模板函数, 你想禁止它接受void*
参数,那么通过下面这样C++98的方法就不能通过编译:
class Widget {
public:
…
template<typename T>
void processPointer(T* ptr)
{ … }
private:
template<> //错误!
void processPointer<void>(void*);
};
问题是模板特例化必须位于一个命名空间作用域,而不是类作用域。deleted函数不会出现这个问题,因为它不需要一个不同的访问级别,且他们可以在类外被删除(因此位于命名空间作用域):
class Widget {
public:
…
template<typename T>
void processPointer(T* ptr)
{ … }
…
};
template<> //还是public,
void Widget::processPointer<void>(void*) = delete; //但是已经被删除了
结论
- 比起声明函数为private但不定义,使用deleted函数更好
- 任何函数都能被删除(be deleted),包括非成员函数和模板实例
重写函数加override声明
优点
1. 编译时能检查错误
编译时能检查错误
要想重写一个函数,必须满足下列要求:
- 基类函数必须是
virtual
- 基类和派生类函数名必须完全一样(除非是析构函数)
- 基类和派生类函数形参类型必须完全一样
- 基类和派生类函数常量性
const
ness必须完全一样 - 基类和派生类函数的返回值和异常说明(exception specifications)必须兼容
除了这些C++98就存在的约束外,C++11又添加了一个:
- 函数的引用限定符(reference qualifiers)必须完全一样。成员函数的引用限定符是C++11很少抛头露脸的特性,所以如果你从没听过它无需惊讶。它可以限定成员函数只能用于左值或者右值。成员函数不需要
virtual
也能使用它们:
下面的代码是完全合法的,但是它没有任何虚函数重写:
class Base {
public:
virtual void mf1() const;
virtual void mf2(int x);
virtual void mf3() &;
void mf4() const;
};
class Derived: public Base {
public:
virtual void mf1();
virtual void mf2(unsigned int x);
virtual void mf3() &&;
void mf4() const;
};
这些代码编译时没有任何warnings,(其他编译器可能会为这些问题的部分输出warnings,但不是全部。)由于正确声明派生类的重写函数很重要,但很容易出错,C++11提供一个方法让你可以显式地指定一个派生类函数是基类版本的重写:将它声明为override
。还是上面那个例子,我们可以这样做:
class Derived: public Base {
public:
virtual void mf1() override;
virtual void mf2(unsigned int x) override;
virtual void mf3() && override;
virtual void mf4() const override;
};
代码不能编译,当然了,因为这样写的时候,编译器会抱怨所有与重写有关的问题。这也是你想要的,以及为什么要在所有重写函数后面加上override
。
结论
- 重写函数添加
override
尽量使用限域枚举
优点
1. 减少域名污染
2. 避免隐式类型转换
3. 拥有默认底层类型
缺点
1. 和std::tuple一起使用时,没有非限域enum
方便
减少域名污染
C++98风格的enum
中声明的枚举名的名字属于包含这个enum
的作用域,这意味着作用域内不能含有相同名字的其他东西:
enum Color { black, white, red }; //black, white, red在
//Color所在的作用域
auto white = false; //错误! white早已在这个作用
//域中声明
这些枚举名的名字泄漏进它们所被定义的enum
在的那个作用域,这个事实有一个官方的术语:未限域枚举(unscoped enum
)。C++11中的限域枚举(scoped enum
),可以避免枚举名泄漏:
enum class Color { black, white, red }; //black, white, red
//限制在Color域内
auto white = false; //没问题,域内没有其他“white”
Color c = white; //错误,域中没有枚举名叫white
Color c = Color::white; //没问题
auto c = Color::white; //也没问题(也符合Item5的建议)
因为限域enum
是通过“enum class
”声明,所以它们有时候也被称为枚举类(enum
classes)
避免隐式类型转换
对于非限域enum
,下列代码是有效的:
enum Color { black, white, red }; //未限域enum
std::vector<std::size_t> //func返回x的质因子
primeFactors(std::size_t x);
Color c = red;
…
if (c < 14.5) { // Color与double比较 (!)
auto factors = // 计算一个Color的质因子(!)
primeFactors(c);
…
}
在enum
后面写一个class
就可以将非限域enum
转换为限域enum
。现在不存在任何隐式转换可以将限域enum
中的枚举名转化为任何其他类型:
enum class Color { black, white, red }; //Color现在是限域enum
Color c = Color::red; //和之前一样,只是
... //多了一个域修饰符
if (c < 14.5) { //错误!不能比较
//Color和double
auto factors = //错误!不能向参数为std::size_t
primeFactors(c); //的函数传递Color参数
…
}
如果你真的很想执行Color
到其他类型的转换,和平常一样,使用正确的类型转换运算符扭曲类型系统:
if (static_cast<double>(c) < 14.5) { //奇怪的代码,
//但是有效
auto factors = //有问题,但是
primeFactors(static_cast<std::size_t>(c)); //能通过编译
…
}
拥有默认底层类型
默认情况下,限域枚举的底层类型是int
:
enum class Status; //底层类型是int
如果默认的int
不适用,你可以重写它:
enum class Status: std::uint32_t; //Status的底层类型
//是std::uint32_t
//(需要包含 <cstdint>)
不管怎样,编译器都知道限域enum
中的枚举名占用多少字节。
要为非限域enum
指定底层类型,你可以同上,结果就可以前向声明:
enum Color: std::uint8_t; //非限域enum前向声明
//底层类型为
//std::uint8_t
底层类型说明也可以放到enum
定义处:
enum class Status: std::uint32_t { good = 0,
failed = 1,
incomplete = 100,
corrupt = 200,
audited = 500,
indeterminate = 0xFFFFFFFF
};
和std::tuple一起使用时,没有非限域enum
方便
比如在社交网站中,假设我们有一个tuple保存了用户的名字,email地址,声望值:
using UserInfo = //类型别名,参见Item9
std::tuple<std::string, //名字
std::string, //email地址
std::size_t> ; //声望
虽然注释说明了tuple各个字段对应的意思,但当你在另一文件遇到下面的代码那之前的注释就不是那么有用了:
UserInfo uInfo; //tuple对象
…
auto val = std::get<1>(uInfo); //获取第一个字段
可以使用非限域enum
将名字和字段编号关联起来以避免上述需求:
enum UserInfoFields { uiName, uiEmail, uiReputation };
UserInfo uInfo; //同之前一样
…
auto val = std::get<uiEmail>(uInfo); //啊,获取用户email字段的值
之所以它能正常工作是因为UserInfoFields
中的枚举名隐式转换成std::size_t
了,其中std::size_t
是std::get
模板实参所需的。
对应的限域enum
版本就很啰嗦了:
enum class UserInfoFields { uiName, uiEmail, uiReputation };
UserInfo uInfo; //同之前一样
…
auto val =
std::get<static_cast<std::size_t>(UserInfoFields::uiEmail)>
(uInfo);
结论
- 限域
enum
的枚举名仅在enum
内可见,避免命名空间污染。 - 限域
enum
的枚举名要转换为其它类型只能使用cast,不能隐式转换。 - 非限域/限域
enum
都支持底层类型说明语法,限域enum
底层类型默认是int
。非限域enum
没有默认底层类型,限域enum
总是可以前置声明。非限域enum
仅当指定它们的底层类型时才能前置。
尽量使用constexpr
优点
1. constexpr
对象的值编译期可知
2. constexpr
函数的双重性
constexpr对象的值编译期可知
简而言之,所有constexpr
对象都是const
,但不是所有const
对象都是constexpr
。
int sz; //non-constexpr变量
…
constexpr auto arraySize1 = sz; //错误!sz的值在
//编译期不可知
std::array<int, sz> data1; //错误!一样的问题
constexpr auto arraySize2 = 10; //没问题,10是
//编译期可知常量
std::array<int, arraySize2> data2; //没问题, arraySize2是constexpr
注意const
不提供constexpr
所能保证之事,因为const
对象不需要在编译期初始化它的值。
int sz; //和之前一样
…
const auto arraySize = sz; //没问题,arraySize是sz的const复制
std::array<int, arraySize> data; //错误,arraySize值在编译期不可知
constexpr函数的双重性
对于constexpr
函数,如果实参是编译期常量,这些函数将产出编译期常量;如果实参是运行时才能知道的值,它们就将产出运行时值:
constexpr
函数可以用于需求编译期常量的上下文。如果你传给constexpr
函数的实参在编译期可知,那么结果将在编译期计算。如果实参的值在编译期不知道,你的代码就会被拒绝。- 当一个
constexpr
函数被一个或者多个编译期不可知值调用时,它就像普通函数一样,运行时计算它的结果。
这意味着你不需要两个函数,一个用于编译期计算,一个用于运行时计算。constexpr
全做了。
因为constexpr
函数必须能在编译期值调用的时候返回编译期结果,就必须对它的实现施加一些限制。这些限制在C++11和C++14标准间有所出入。
C++11中,constexpr
函数的代码不超过一行语句:一个return
。听起来很受限,但实际上有两个技巧可以扩展constexpr
函数的表达能力。第一,使用三元运算符“?:
”来代替if
-else
语句,第二,使用递归代替循环。因此pow
可以像这样实现:
constexpr int pow(int base, int exp) noexcept
{
return (exp == 0 ? 1 : base * pow(base, exp - 1));
}
这样没问题,但是很难想象除了使用函数式语言的程序员外会觉得这样硬核的编程方式更好。在C++14中,constexpr
函数的限制变得非常宽松了,所以下面的函数实现成为了可能:
constexpr int pow(int base, int exp) noexcept //C++14
{
auto result = 1;
for (int i = 0; i < exp; ++i) result *= base;
return result;
}
constexpr
函数限制为只能获取和返回字面值类型,这基本上意味着那些有了值的类型能在编译期决定。在C++11中,除了void
外的所有内置类型,以及一些用户定义类型都可以是字面值类型,因为构造函数和其他成员函数可能是constexpr
:
class Point {
public:
constexpr Point(double xVal = 0, double yVal = 0) noexcept
: x(xVal), y(yVal)
{}
constexpr double xValue() const noexcept { return x; }
constexpr double yValue() const noexcept { return y; }
void setX(double newX) noexcept { x = newX; }
void setY(double newY) noexcept { y = newY; }
private:
double x, y;
};
Point
的构造函数可被声明为constexpr
,因为如果传入的参数在编译期可知,Point
的数据成员也能在编译器可知。因此这样初始化的Point
就能为constexpr
:
constexpr Point p1(9.4, 27.7); //没问题,constexpr构造函数
//会在编译期“运行”
constexpr Point p2(28.8, 5.3); //也没问题
类似的,xValue
和yValue
的getter(取值器)函数也能是constexpr
,因为如果对一个编译期已知的Point
对象(如一个constexpr
Point
对象)调用getter,数据成员x
和y
的值也能在编译期知道。这使得我们可以写一个constexpr
函数,里面调用Point
的getter并初始化constexpr
的对象:
constexpr
Point midpoint(const Point& p1, const Point& p2) noexcept
{
return { (p1.xValue() + p2.xValue()) / 2, //调用constexpr
(p1.yValue() + p2.yValue()) / 2 }; //成员函数
}
constexpr auto mid = midpoint(p1, p2); //使用constexpr函数的结果
//初始化constexpr对象
这太令人激动了。它意味着mid
对象通过调用构造函数,getter和非成员函数来进行初始化过程就能在只读内存中被创建出来!它也意味着你可以在模板实参或者需要枚举名的值的表达式里面使用像mid.xValue() * 10
的表达式!(因为Point::xValue
返回double
,mid.xValue() * 10
也是个double
。浮点数类型不可被用于实例化模板或者说明枚举名的值,但是它们可以被用来作为产生整数值的大表达式的一部分。比如,static_cast<int>(mid.xValue() * 10)
可以被用来实例化模板或者说明枚举名的值。)它也意味着以前相对严格的编译期完成的工作和运行时完成的工作的界限变得模糊,一些传统上在运行时的计算过程能并入编译时。越多这样的代码并入,你的程序就越快。(然而,编译会花费更长时间)
在C++11中,有两个限制使得Point
的成员函数setX
和setY
不能声明为constexpr
。第一,它们修改它们操作的对象的状态, 并且在C++11中,constexpr
成员函数是隐式的const
。第二,它们有void
返回类型,void
类型不是C++11中的字面值类型。这两个限制在C++14中放开了,所以C++14中Point
的setter(赋值器)也能声明为constexpr
:
class Point {
public:
…
constexpr void setX(double newX) noexcept { x = newX; } //C++14
constexpr void setY(double newY) noexcept { y = newY; } //C++14
…
};
现在也能写这样的函数:
//返回p相对于原点的镜像
constexpr Point reflection(const Point& p) noexcept
{
Point result; //创建non-const Point
result.setX(-p.xValue()); //设定它的x和y值
result.setY(-p.yValue());
return result; //返回它的副本
}
客户端代码可以这样写:
constexpr Point p1(9.4, 27.7); //和之前一样
constexpr Point p2(28.8, 5.3);
constexpr auto mid = midpoint(p1, p2);
constexpr auto reflectedMid = //reflectedMid的值
reflection(mid); //(-19.1, -16.5)在编译期可知
本条款的建议是尽可能的使用constexpr
,现在我希望大家已经明白缘由:constexpr
对象和constexpr
函数可以使用的范围比non-constexpr
对象和函数大得多。使用constexpr
关键字可以最大化你的对象和函数可以使用的场景。
还有个重要的需要注意的是constexpr
是对象和函数接口的一部分。加上constexpr
相当于宣称“我能被用在C++要求常量表达式的地方”。如果你声明一个对象或者函数是constexpr
,客户端程序员就可能会在那些场景中使用它。如果你后面认为使用constexpr
是一个错误并想移除它,你可能造成大量客户端代码不能编译。(为了debug或者性能优化而添加I/O到一个函数中这样简单的动作可能就导致这样的问题,因为I/O语句一般不被允许出现在constexpr
函数里)“尽可能”的使用constexpr
表示你需要长期坚持对某个对象或者函数施加这种限制。
结论
constexpr
对象是cosnt
,它被在编译期可知的值初始化- 当传递编译期可知的值时,
cosntexpr
函数可以产出编译期可知的结果 constexpr
对象和函数可以使用的范围比non-constexpr
对象和函数要大constexpr
是对象和函数接口的一部分
谨慎使用{}初始化
优点
1. 使用范围广
2. 避免隐式变窄转换
3. 避免解析问题
缺点
1. 奇怪的构造函数重载决议
2. 模板中使用问题
使用范围广
C++11基于花括号提出统一初始化(uniform initialization)的概念,用来整合初始化语法。
使用花括号,指定一个容器的元素变得很容易:
std::vector<int> v{ 1, 3, 5 }; //v初始内容为1,3,5
花括号初始化也能被用于为非静态数据成员指定默认初始值。C++11允许"="初始化不加花括号也拥有这种能力:
class Widget{
…
private:
int x{ 0 }; //没问题,x初始值为0
int y = 0; //也可以
int z(0); //错误!
}
另一方面,不可拷贝的对象(例如std::atomic
)可以使用花括号初始化或者小括号初始化,但是不能使用"="初始化:
std::atomic<int> ai1{ 0 }; //没问题
std::atomic<int> ai2(0); //没问题
std::atomic<int> ai3 = 0; //错误!
因此我们很容易理解为什么花括号初始化又叫统一初始化,在C++中这三种方式都被指派为初始化表达式,但是只有花括号任何地方都能被使用。
避免隐式变窄转换
如果一个使用了花括号初始化的表达式的值,不能保证由被初始化的对象的类型来表示,代码就不会通过编译:
double x, y, z;
int sum1{ x + y + z }; //错误!double的和可能不能表示为int
使用小括号和"="的初始化不检查是否转换为变窄转换,因为由于历史遗留问题它们必须要兼容老旧代码:
int sum2(x + y +z); //可以(表达式的值被截为int)
int sum3 = x + y + z; //同上
避免解析问题
另一个值得注意的特性是括号表达式对于C++最令人头疼的解析问题有天生的免疫性。(更多信息请参见https://en.wikipedia.org/wiki/Most_vexing_parse。)问题的根源是如果你想使用一个实参调用一个构造函数,你可以这样做:
Widget w1(10); //使用实参10调用Widget的一个构造函数
但是如果你尝试使用相似的语法调用没有参数的Widget
构造函数,它就会变成函数声明:
Widget w2(); //最令人头疼的解析!声明一个函数w2,返回Widget
由于函数声明中形参列表不能使用花括号,所以使用花括号初始化表明你想调用默认构造函数构造对象就没有问题:
Widget w3{}; //调用没有参数的构造函数构造对象
奇怪的构造函数重载决议
在构造函数调用中,只要不包含std::initializer_list
形参,那么花括号初始化和小括号初始化都会产生一样的结果:
class Widget {
public:
Widget(int i, bool b); //构造函数未声明
Widget(int i, double d); //std::initializer_list形参
…
};
Widget w1(10, true); //调用第一个构造函数
Widget w2{10, true}; //也调用第一个构造函数
Widget w3(10, 5.0); //调用第二个构造函数
Widget w4{10, 5.0}; //也调用第二个构造函数
然而,如果有一个或者多个构造函数的声明一个std::initializer_list
形参,使用括号初始化语法的调用更倾向于适用std::initializer_list
重载函数。而且只要某个使用括号表达式的调用能适用接受std::initializer_list
的构造函数,编译器就会使用它。如果上面的Widget
类有一个std::initializer_list<long double>
构造函数并被传入实参,就像这样:
class Widget {
public:
Widget(int i, bool b); //同上
Widget(int i, double d); //同上
Widget(std::initializer_list<long double> il); //新添加的
…
};
w2
和w4
将会使用新添加的构造函数构造,即使另一个非std::initializer_list
构造函数对于实参是更好的选择:
Widget w1(10, true); //使用小括号初始化,同之前一样
//调用第一个构造函数
Widget w2{10, true}; //使用花括号初始化,但是现在
//调用std::initializer_list版本构造函数
//(10 和 true 转化为long double)
Widget w3(10, 5.0); //使用小括号初始化,同之前一样
//调用第二个构造函数
Widget w4{10, 5.0}; //使用花括号初始化,但是现在
//调用std::initializer_list版本构造函数
//(10 和 true 转化为long double)
甚至普通的构造函数和移动构造函数都会被std::initializer_list
构造函数劫持:
class Widget {
public:
Widget(int i, bool b); //同之前一样
Widget(int i, double d); //同之前一样
Widget(std::initializer_list<long double> il); //同之前一样
operator float() const; //转换为float
…
};
Widget w5(w4); //使用小括号,调用拷贝构造函数
Widget w6{w4}; //使用花括号,调用std::initializer_list构造
//函数(w4转换为float,float转换为double)
Widget w7(std::move(w4)); //使用小括号,调用移动构造函数
Widget w8{std::move(w4)}; //使用花括号,调用std::initializer_list构造
//函数(与w6相同原因)
编译器热衷于把括号初始化与使std::initializer_list
构造函数匹配了,尽管最佳匹配std::initializer_list
构造函数不能被调用也会凑上去。比如:
class Widget {
public:
Widget(int i, bool b); //同之前一样
Widget(int i, double d); //同之前一样
Widget(std::initializer_list<bool> il); //现在元素类型为bool
… //没有隐式转换函数
};
Widget w{10, 5.0}; //错误!要求变窄转换
这里,编译器会直接忽略前面两个构造函数(其中第二个提供了所有实参类型的最佳匹配),然后尝试调用std::initializer_list<bool>
构造函数。调用这个函数将会把int(10)
和double(5.0)
转换为bool
,由于会产生变窄转换(bool
不能准确表示其中任何一个值),括号初始化拒绝变窄转换,所以这个调用无效,代码无法通过编译。
只有当没办法把括号初始化中实参的类型转化为std::initializer_list
时,编译器才会回到正常的函数决议流程中。比如我们在构造函数中用std::initializer_list<std::string>
代替std::initializer_list<bool>
,这时非std::initializer_list
构造函数将再次成为函数决议的候选者,因为没有办法把int
和bool
转换为std::string
:
class Widget {
public:
Widget(int i, bool b); //同之前一样
Widget(int i, double d); //同之前一样
//现在std::initializer_list元素类型为std::string
Widget(std::initializer_list<std::string> il);
… //没有隐式转换函数
};
Widget w1(10, true); // 使用小括号初始化,调用第一个构造函数
Widget w2{10, true}; // 使用花括号初始化,现在调用第一个构造函数
Widget w3(10, 5.0); // 使用小括号初始化,调用第二个构造函数
Widget w4{10, 5.0}; // 使用花括号初始化,现在调用第二个构造函数
代码的行为和我们刚刚的论述如出一辙。这里还有一个有趣的edge case。假如你使用的花括号初始化是空集,并且你欲构建的对象有默认构造函数,也有std::initializer_list
构造函数。你的空的花括号意味着什么?如果它们意味着没有实参,就该使用默认构造函数,但如果它意味着一个空的std::initializer_list
,就该调用std::initializer_list
构造函数。
最终会调用默认构造函数。空的花括号意味着没有实参,不是一个空的std::initializer_list
:
class Widget {
public:
Widget(); //默认构造函数
Widget(std::initializer_list<int> il); //std::initializer_list构造函数
… //没有隐式转换函数
};
Widget w1; //调用默认构造函数
Widget w2{}; //也调用默认构造函数
Widget w3(); //最令人头疼的解析!声明一个函数
如果你想用空std::initializer
来调用std::initializer_list
构造函数,你就得创建一个空花括号作为函数实参——通过把空花括号放在小括号或者另一花括号内来界定你想传递的东西。
Widget w4({}); //使用空花括号列表调用std::initializer_list构造函数
Widget w5{{}}; //同上
数值类型的vector,两个中初始化方式的区别:
std::vector<int> v1(10, 20); //使用非std::initializer_list构造函数
//创建一个包含10个元素的std::vector,
//所有的元素的值都是20
std::vector<int> v2{10, 20}; //使用std::initializer_list构造函数
//创建包含两个元素的std::vector,
//元素的值为10和20
模板中使用问题
如果你是一个模板的作者,花括号和小括号创建对象就更麻烦了。通常不能知晓哪个会被使用。举个例子,假如你想创建一个接受任意数量的参数,然后用它们创建一个对象。使用可变参数模板(variadic template)可以非常简单的解决:
template<typename T, //要创建的对象类型
typename... Ts> //要使用的实参的类型
void doSomeWork(Ts&&... params)
{
create local T object from params...
…
}
在现实中我们有两种方式实现这个伪代码(关于std::forward
请参见Item25):
T localObject(std::forward<Ts>(params)...); //使用小括号
T localObject{std::forward<Ts>(params)...}; //使用花括号
考虑这样的调用代码:
std::vector<int> v;
…
doSomeWork<std::vector<int>>(10, 20);
如果doSomeWork
创建localObject
时使用的是小括号,std::vector
就会包含10个元素。如果doSomeWork
创建localObject
时使用的是花括号,std::vector
就会包含2个元素。哪个是正确的?doSomeWork
的作者不知道,只有调用者知道。
这正是标准库函数std::make_unique
和std::make_shared
(参见Item21)面对的问题。它们的解决方案是使用小括号,并被记录在文档中作为接口的一部分。(注:更灵活的设计——允许调用者决定从模板来的函数应该使用小括号还是花括号——是有可能的。详情参见Andrzej’s C++ blog在2013年6月5日的文章,“Intuitive interface — Part I.”)
结论
- 括号初始化是最广泛使用的初始化语法,它防止变窄转换,并且对于C++最令人头疼的解析有天生的免疫性
- 在构造函数重载决议中,括号初始化尽最大可能与
std::initializer_list
参数匹配,即便其他构造函数看起来是更好的选择 - 对于数值类型的
std::vector
来说使用花括号初始化和小括号初始化会造成巨大的不同 - 在模板类选择使用小括号初始化或使用花括号初始化创建对象是一个挑战。
优先使用nullptr而非NULL和0
优点
1. 避免奇怪的函数重载决议
2. 代码表意明确
3. 模板中使用nullptr
避免奇怪的函数重载决议
如果给下面的重载函数传递0
或NULL
,它们绝不会调用指针版本的重载函数:
void f(int); //三个f的重载函数
void f(bool);
void f(void*);
f(0); //调用f(int)而不是f(void*)
f(NULL); //可能不会被编译,一般来说调用f(int),
//绝对不会调用f(void*)
源代码表现出的意思(“我使用空指针NULL
调用f
”)和实际表达出的意思(“我是用整型数据而不是空指针调用f
”)是相矛盾的,而使用nullptr
调用f
将会调用void*
版本的重载函数,因为nullptr
不能被视作任何整型:
f(nullptr); //调用重载函数f的f(void*)版本
代码表意明确
nullptr也可以使代码表意明确,尤其是当涉及到与auto
声明的变量一起使用时。比如下面这段代码:
auto result = findRecord( /* arguments */ );
if (result == 0) {
…
}
如果你不知道findRecord
返回了什么,那么你就不太清楚到底result
是一个指针类型还是一个整型。换成下面这种写法:
auto result = findRecord( /* arguments */ );
if (result == nullptr) {
…
}
这就没有任何歧义:result
的结果一定是指针类型。
模板中使用nullptr
当模板出现时nullptr
就更有用了。假如你有一些函数只能被合适的已锁互斥量调用。每个函数都有一个不同类型的指针:
int f1(std::shared_ptr<Widget> spw); //只能被合适的
double f2(std::unique_ptr<Widget> upw); //已锁互斥量
bool f3(Widget* pw); //调用
std::mutex f1m, f2m, f3m; //用于f1,f2,f3函数的互斥量
using MuxGuard = //C++11的typedef,参见Item9
std::lock_guard<std::mutex>;
…
template<typename FuncType,
typename MuxType,
typename PtrType>
auto lockAndCall(FuncType func,
MuxType& mutex,
PtrType ptr) -> decltype(func(ptr))
{
MuxGuard g(mutex);
return func(ptr);
}
可以写这样的代码调用lockAndCall
模板(两个版本都可):
auto result1 = lockAndCall(f1, f1m, 0); //错误!
...
auto result2 = lockAndCall(f2, f2m, NULL); //错误!
...
auto result3 = lockAndCall(f3, f3m, nullptr); //没问题
模板类型推导会尝试去推导实参类型,
在第一个调用中0
被传递给lockAndCall
,形参ptr
被推导为int
,与f1
期待的std::shared_ptr<Widget>
形参不符,出现类型错误。
第二个调用中NULL
被传递给lockAndCall
,形参ptr
被推导为整型,与f2
期待的std::unique_ptr<Widget>
形参不符,出现类型错误。
第三个调用中nullptr
传给lockAndCall
,形参ptr
被推导为std::nullptr_t
。当ptr
被传递给f3
的时候,隐式转换使std::nullptr_t
转换为Widget
,因为std::nullptr_t
可以隐式转换为任何指针类型。
结论
- 优先考虑
nullptr
而非0
和NULL
- 避免重载指针和整型
优先使用using别名声明而不是typedef
using 的别名语法覆盖了 typedef 的全部功能。先来看看对普通类型的重定义示例,将这两种语法对比一下:
// 重定义unsigned int
typedef unsigned int uint_t;
using uint_t = unsigned int;
// 重定义std::map
typedef std::map<std::string, int> map_int_t;
using map_int_t = std::map<std::string, int>;
可以看到,在重定义普通类型上,两种使用方法的效果是等价的,唯一不同的是定义语法。
typedef 的定义方法和变量的声明类似:像声明一个变量一样,声明一个重定义类型,之后在声明之前加上 typedef 即可。这种写法凸显了 C/C++ 中的语法一致性,但有时却会增加代码的阅读难度。比如重定义一个函数指针时:
typedef void (*func_t)(int, int);
与之相比,using 后面总是立即跟随新标识符(Identifier),之后使用类似赋值的语法,把现有的类型(type-id)赋给新类型:
using func_t = void (*)(int, int);
从上面的对比中可以发现,C++11 的 using 别名语法比 typedef 更加清晰。因为 typedef 的别名语法本质上类似一种解方程的思路。而 using 语法通过赋值来定义别名,和我们平时的思考方式一致。
下面再通过一个对比示例,看看新的 using 语法是如何定义模板别名的。
/* C++98/03 */
template <typename T>
struct func_t
{
typedef void (*type)(T, T);
};
// 使用 func_t 模板
func_t<int>::type xx_1;
/* C++11 */
template <typename T>
using func_t = void (*)(T, T);
// 使用 func_t 模板
func_t<int> xx_2;
从示例中可以看出,通过 using 定义模板别名的语法,只是在普通类型别名语法的基础上增加 template 的参数列表。使用 using 可以轻松地创建一个新的模板别名,而不需要像 C++98/03 那样使用烦琐的外敷模板。
需要注意的是,using 语法和 typedef 一样,并不会创造新的类型。也就是说,上面示例中 C++11 的 using 写法只是 typedef 的等价物。虽然 using 重定义的 func_t 是一个模板,但 func_t 定义的 xx_2 并不是一个由类模板实例化后的类,而是 void(*)(int, int) 的别名。
结论
typedef
不支持模板化,但是别名声明支持。- 别名模板避免了使用“
::type
”后缀,而且在模板中使用typedef
还需要在前面加上typename
智能指针
- std::shared_ptr
C++ 智能指针底层是采用引用计数的方式实现的。简单的理解,智能指针在申请堆内存空间的同时,会为其配备一个整形值(初始值为 1),每当有新对象使用此堆内存时,该整形值 +1;反之,每当使用此堆内存的对象被释放时,该整形值减 1。当堆空间对应的整形值为 0 时,即表明不再有对象使用它,该堆空间就会被释放掉。 - std::unique_ptr
作为智能指针的一种,unique_ptr 指针自然也具备“在适当时机自动释放堆内存空间”的能力。和 shared_ptr 指针最大的不同之处在于,unique_ptr 指针指向的堆内存无法同其它 unique_ptr 共享,也就是说,每个 unique_ptr 指针都独自拥有对其所指堆内存空间的所有权。
这也就意味着,每个 unique_ptr 指针指向的堆内存空间的引用计数,都只能为 1,一旦该 unique_ptr 指针放弃对所指堆内存空间的所有权,则该空间会被立即释放回收。 - std::weak_ptr
C++11标准虽然将 weak_ptr 定位为智能指针的一种,但该类型指针通常不单独使用(没有实际用处),只能和 shared_ptr 类型指针搭配使用。甚至于,我们可以将 weak_ptr 类型指针视为 shared_ptr 指针的一种辅助工具,借助 weak_ptr 类型指针, 我们可以获取 shared_ptr 指针的一些状态信息,比如有多少指向相同的 shared_ptr 指针、shared_ptr 指针指向的堆内存是否已经被释放等等。
需要注意的是,当 weak_ptr 类型指针的指向和某一 shared_ptr 指针相同时,weak_ptr 指针并不会使所指堆内存的引用计数加 1;同样,当 weak_ptr 指针被释放时,之前所指堆内存的引用计数也不会因此而减 1。也就是说,weak_ptr 类型指针并不会影响所指堆内存空间的引用计数。
除此之外,weak_ptr 模板类中没有重载 * 和 -> 运算符,这也就意味着,weak_ptr 类型指针只能访问所指的堆内存,而无法修改它。
结论
std::unique_ptr
是轻量级、快速的、只可移动(move-only)的管理专有所有权语义资源的智能指针- 默认情况,资源销毁通过
delete
实现,但是支持自定义删除器。有状态的删除器和函数指针会增加std::unique_ptr
对象的大小 - 将
std::unique_ptr
转化为std::shared_ptr
非常简单 std::shared_ptr
为有共享所有权的任意资源提供一种自动垃圾回收的便捷方式。- 较之于
std::unique_ptr
,std::shared_ptr
对象通常大两倍,控制块会产生开销,需要原子性的引用计数修改操作。 - 默认资源销毁是通过
delete
,但是也支持自定义删除器。删除器的类型是什么对于std::shared_ptr
的类型没有影响。 - 避免从原始指针变量上创建
std::shared_ptr
。 - 用
std::weak_ptr
替代可能会悬空的std::shared_ptr
。 std::weak_ptr
的潜在使用场景包括:缓存、观察者列表、打破std::shared_ptr
环状结构。- 和直接使用
new
相比,make
函数消除了代码重复,提高了异常安全性。对于std::make_shared
和std::allocate_shared
,生成的代码更小更快。 - 不适合使用
make
函数的情况包括需要指定自定义删除器和希望用花括号初始化。 - 对于
std::shared_ptr
s,其他不建议使用make
函数的情况包括(1)有自定义内存管理的类;(2)特别关注内存的系统,非常大的对象,以及std::weak_ptr
s比对应的std::shared_ptr
s活得更久。