Modern C++ 学习笔记——易用性改进篇

学习笔记 专栏收录该内容
10 篇文章 0 订阅

往期精彩:

Modern C++ 学习笔记——易用性改进篇

关键字:自动类型推导、初始化、字面量

自动类型推导

auto

自动类型推导,就是编译器能够更加表达式的类型,自动决定变量的类型,从C++14开始,还有函数的返回类型。但需要说明的是,auto并没有改变C++是静态语言这一事实。使用auto的变量类型仍然是编译时就确定了,只不过编译器能自动帮你填充而已。有了自动类型推导使得如下赘述称为历史

for (vector<int>::iterator it = v.begin(); it != v.end(); ++it) { // 成为历史
    // 循环体
}
for (auto it = v.begin(); it != end(); ++it) { // 现在可以直接这么写,当然,是不使用基于范围的for循环的情况
    // 循环体
}

不使用自动类型推导时,若容器类型未知,还需要加上typename.

template <typename T>
void foo(const T& container) // 此处const引用还要求const_iterator作为迭代器的类型
{
    for (typename T::const_iterator it = container.begin(); it != container.end(); ++it) {
        // 循环体
    }
}

此外,如果begin返回的类型不是该类型的const_iterator嵌套类型的话,那实际上不用自动类型推断就没法表达了。举个例子,若我们的遍历函数还要求支持C数组的话,不使用自动类型推断就只能在上述代码增加一个对应的重载函数:

template <typename T, size_t N>
void foo(const T (&a)[N]) // 此处const引用还要求const_iterator作为迭代器的类型
{
    typedef const T* ptr;
    for (ptr it = container.begin(); it != a + N; ++it) {
        // 循环体
    }
}

如果使用自动类型推导,并且再加上C++11提供的begin和end函数,上面的代码就以统一了:

template <typename T>
void foo(const T& c)
{
    using std::begin;
    using std::end; // 使用依赖参数查找(ADL)见[1]
    for (auto it = begin(c); it != end(c); ++it) {
    // 循环体
    }
}

从这个例子来看,自动类型推导不仅降低了代码的啰嗦程度,也提高了代码的抽象性。
你以为auto带来只是有这些可就错了,它带来的好处远远不止如此:

  • 用auto声明的变量,其型别都推导自其初始化物,所以他们必须初始化:
int x1; // 存在潜在的未初始化风险
auto x2; // 编译错误!必须有初始化物
auto x3 = 0; // 没问题
  • auto可以避免一类称为“型别捷径”的问题,其中潜在存在性能问题,没错就是性能问题。不太相信?那就继续往下看:
std::unordered_map<std::string, int> m;
...
for (const std::pair<std::string, int> &p : m) {
    // 在p上实施某些操作
}

似乎,这块代码看起来合情合理。单其中暗藏隐患。原因是std::unordered_map的键值部分是const,所以哈希表中的std::pair(也就是std::unordered_map本身)的型别并不是std::pair,而是std::pair.
可是在上面的循环中用以声明变量p的类型并不是这个。结果编译器为了将std::pair对象转换为std::pair对象,将m中的每个对象都做了一次复制操作,形成一个p想要绑定的临时对象。循环的每次迭代结束时,该临时对象都会被析构一次。

使用auto就可以轻松化解:

unordered_map<std::string, int> m{{"hello", 12}};
cout << &(*(m.begin())) << endl; // 0x1e1668
for (const pair<std::string, int> &p : m) {
    cout << &p <<endl; // 0x63fdc0
}
for (const auto &p : m) {
    cout << &p <<endl; // 0x1e1668
}
  • 此外,由于auto使用了类型推导,它就可以表示只有编译才掌握的型别,直接持有闭包[2]:
auto derefUPLess = [](const std::unique_ptr<Widget>& p1, const std::unique_ptr<Widget>& p2)
{ return *p1 < *p2; };                  // std::unique_ptrs

更有甚者,在C++14中,lambda表达式的形参都可以使用auto:

auto derefUPLess = [](const auto& p1, const auto& p2)
{ return *p1 < *p2; };                  // std::unique_ptrs
  • 最后,针对于“隐形”的代理型别可以导致auto根据初始化表达式推导出“错误的”型别这一问题,在《Effective Modern C++》中给出了一个具体的解决方案,详细可以见条款6。

decltype

C++11中,decltype的主要用途是获得一个表达式的类型,结果可以跟类型一样使用,它有两个基本用法:

  • decltype(变量名)可以获得变量的精确类型。
  • decltype(表达式)可以获得表达式的引用类型;除非表达式的结果是个纯右值(prvalue),此时结果仍然是值类型。 为了更好理解,举例来说明,假设我们有int x = 0;,那么:
  • decltype(x) 会得到int(因为x是int)
  • decltype(x + x) 会得到int(因为x + x是纯右值)
  • decltype((x)) 会得到int&(因为x是lvalue) 对于最后这种情况,可以总结为,对于型别为T的左值表达式,除非该表达式仅有一个名字,decltype总是得出型别T&。在C++11中知道了这个也就是满足好奇心而已,但是如果与接下来要聊到的C++14支持的decltype(auto)联合一下,就会影响到函数型别的推导结果。

decltype(auto)

假设有如下模板函数目的是得知容器的operator[]的返回类型。

template<typename Container, typename Index>
auto authAndAccess(Container& c, Index i) -> decltype(c[i])
{
    //...
    return c[i];
}

这儿使用了返回值类型尾序语法(若使用传统的返回值类型先序语法,那c和i会由于还未声明从而无法使用。),此时authAndAccess的返回值型别与我们期望的结果一致。
C++11允许对单表达式的lambda式的返回值类别实施推导,而C++14将这个允许返回扩张到了一切lambda式和一切函数。对于authAndAccess就可以修改为:

template<typename Container, typename Index>
auto authAndAccess(Container& c, Index i) // 去掉返回值尾序语法
{
    //...
    return c[i]; // auto根据c[i]推导出来
}

上述的代码中留有隐患,我们来花一定的篇幅来解释这个问题。对于大多数含有类型T的对象的容器的operator[]会返回T&,但是在类型推导时,初始化表达的引用性会被忽略。考虑如下代码:

std::vector<int> v;
...
authAndAccess(v, 5) = 10; // 这段代码无法通过编译

此处,d[5]返回的是int&,但是对authAndAccess的返回值实施auto类型推导将剥夺引用饰词,即int;作为函数的返回值,该int是个右值,所以上述代码其实尝试将10赋值给一个右值int,这在C++中被禁止。

error: lvalue required as left operand of assignment
authAndAccess(v, 1) = 10;
                    ^

在C++14中通过decltype(auto)饰词解决了这个问题。auto制定了欲实施推导的类型,而推导过程中采用的是decltype的规则。上述的代码在C++14就可以写成:

template<typename Container, typename Index> // C++14最终版
decltype(auto) authAndAccess(Container&& c, Index i) // 使用万能引用
{
    //...
    return std::forward<container>(c)[i]; // 应用std::forward配合万能引用
}
template<typename Container, typename Index> // C++11最终版
decltype(auto) authAndAccess(Container&& c, Index i) // 使用万能引用
    -> decltype(std::forward<container>(c)[i])
{
//...
return std::forward<container>(c)[i]; // 应用std::forward配合万能引用
}

从C++14开始,函数的返回值可以通过auto或者decltype(auto)来声明了。用auto可以得到值类型,用auto&或auto&&得到引用类型。而用decltype(auto)可以根据返回表达式通用地决定返回的是值类型还是引用类型。

Widget w;
const Widget& cw = w;
auto myWidget1 = cw; // auto类型推导:myWidget1的类型推导为 Widget
decltype(auto) myWidget2 = cw; // decltype类型推导:myWidget2的类型推导为 const Widget&

在联合之前提到的,观察下列代码:

decltype(auto) f1(){
    int x = 0;
    ...
    return x; // decltype(x)是int,所以f1返回的是int
}
decltype(auto) f1(){
    int x = 0;
    ...
    return (x); // decltype((x))是int&,所以f1返回的是int&
}

请注意,问题不仅仅在与返回值的类型不同,更重要的是f2返回了一个局部变量的引用!!!这种代码会将你送上未定义行为的快车。

类模板的模板参数推导

在C++17之前类模板并没有函数模板参数推导这个功能,导致产生了像make_pair这样的工具函数。

// C++17之前的用法
pair<int, int> pr{1, 42};
auto pr = make_pair(1, 42);

在C++17之后,世界就会变得简单起来,我们可以直接这么写:

pair pr{1, 42};

对于array也有了更方便的用法。

//C++17之前
int a1[] = {1, 2, 3}; // C数组初始化,自动从初始化列表推断数组大小
array<int, 3> a2{1, 2, 3}; //啰嗦
// array<int> a3{1, 2, 3} 不行
//C++17
array a{1, 2, 3}; // 得到array<int, 3>

详细可见参考资料[4].

结构化绑定

C++17引入了一个新语法——结构化绑定[5],使得我们可以通过auto声明变量来分别获取pair和tuple返回值里各个子项,可以让代码可读性更好。

// C++17 采用结构化绑定声明了迭代类型的 iter 和 bool 类型的 success,分别绑定了初始化表达式中 pair 对象的 first 和 second。
set<string> mySet;
if (auto [iter, success] = mySet.insert("Hello"); success)
cout << *iter <<endl; // Hello
// C++14等价代码
set<string> mySet;
set<string>::iterator iter;
bool success;
tie(iter, success) = mySet.insert("hello");
if (success) cout << *iter <<endl; // hello

初始化

长时间写C++98都会遇到初始化令人头问题的情况。比如:

int a[] = {1, 2, 3, 4, 5}; //C风格数组,初始化方便
vector<int> v; //C++98
v.push(1); // 啰嗦,性能差
v.push(2);
v.push(3);
v.push(4);
v.push(5);

在现代C++中终于迎来了曙光。

列表初始化

现代C++我们初始化容器也可以和初始化数组一样简单了。

vector<int> v{1, 2, 3, 4, 5};

同样重要的是,这不是对标准库容器的特殊魔法,而是一个通用的、可以用于各种类的方法。编译器这还是对{1,2,3}这样的表达式自动生成一个初始化列表。此例中其类型为initializer_list。参考资料[6]

类数据成员的默认初始化

C++98的语法,数据成员可以在构造函数里进行初始化。这本身不是问题,但是当数据成员多、构造函数又有多个,逐个去初始化是个累赘,并且容易在增加数据成员时漏掉在某个构造函数中进行初始化。为此,C++11增加了一个语法,允许在声明数据成员时直接给予一个初始化表达式。

class Complex { // C++98
public:
    Complex() : re_(0) , im_(0) {}
    Complex(float re) : re_(re), im_(0) {}
    Complex(float re, float im) : re_(re) , im_(im) {}
    //...
private:
    float re_;
    float im_;
};
class Complex { // C++11
public:
    Complex() {}
    Complex(float re) : re_(re) {}
    Complex(float re, float im) : re_(re) , im_(im) {}
private:
    float re_{0};
    float im_{0};
};

当且仅当构造函数的初始化列表中不包含改数据成员时,这个数据成员就会自动使用初始化表达式进行初始化。

创建对象是注意区分()和{}

指定初始化值的方式包括使用小括号、使用等号、或者是使用大括号。或许你似乎有种感觉优先选用大括号初始化是个更优的选择。的确,大括号初始化引用的语境最为广泛,而且可以阻止隐式窄化类型转换。

double x, y, z;
...
int sum1{x + y + z}; // 错误!double类型之和可能无法使用int表述。
int sum2(x + y + z); // 没问题(表达式的值被截断为int)
int sum3 = x + y + z; // 同上

大括号初始化的另一项值得一提的特征是,它对C++的最令人苦恼的解析语法免疫。C++规定:任何能够解析为声明的都要解析为声明。而这会带来副作用。程序员本来想要以默认方式构造一个对象,结果却一不小心声明了一个函数,这个错误的根本原因在与构造函数的调用语法

Widget w1(10) // 调用Widget的构造函数,传入形参10
Widget w2(); // 解析语法现身!声明了一个名为w2,返回一个Widget类型对象的函数!
Widget w3{}; // 使用大括号完成对象的默认构造。调用没有形参的Widget的构造函数。

大括号初始化的缺陷在于伴随它有时会出现意外行为。这种行为源于大括号初始化物、std::initializer_list以及构造函数重载决议之间的纠结关系。如果有一个或者多个构造函数声明了任何一个具备std::initializer_list型别的形参,那么采用了大括号初始化语法的调用语句会强烈地优先选用带有std::initializer_list类型的形参的重载版本。

class Widget {
public:
    Widget(int i, bool b);
    Widget(int i, double b);
    Widget(std::initializer_list<long double> il); 
    ...
};
Widget w1(10, true); // 调用的是第一个构造函数。
Widget w2{10, true}; // 使用大括号,调用的是带有initializer_list类型形参的构造函数。强制转换为long double
Widget w3{10, 5.0}; // 使用大括号,调用的是带有initializer_list类型形参的构造函数。强制转换为long double

即使是平常执行复制或移动的构造函数也可能被带有std::initializer_list类型形参的构造函数劫持:

class Widget {
public:
    Widget(int i, bool b);
    Widget(int i, double b);
    Widget(std::initializer_list<long double> il);
    operator float() const; // 强制转换为float类型
    ...
};
Widget w3(w3); // 使用小括号,调用复制构造函数
Widget w4{w3}; // 使用大括号,调用的是带有initializer_list类型形参的构造函数
//w3的返回值被强制转换为float,最后float又被强制装换成long double
Widget w5(std::move(w3)); // 使用小括号,调用移动构造函数
Widget w6{std::move(w3)}; // 使用大括号,调用的是带有initializer_list类型形参的构造函数

或许你觉得聊这些知识与日常程序设计有什么关系。实际影响比你想象的要大,因为直接受到影响的一个类就是std::vector。

std::vector<int> v1(10, 20);
// 调用形参中没有任何一个具备std::initializer_iter类型的构造函数。结果是:创建了一个含有10个元素的std::vector。所有元素的值都是20.
std::vector<int> v2{10, 20};
// 调用了形参中含有std::initializer_iter类型的构造函数。结果是:创建了一个含有2个元素的std::vector,元素的值非别是10和20.

从以上的讨论中可以得到两个结论:

  • 作为一个类的作者,你必须清楚的意识到,当编写了一组构造函数中只要有一个或多个声明了任何一个具备initializer_list类型的形参,则使用大括号初始化的代码可能被劫持。(视std::vector的接口设计为败笔,从中吸取教训,避免同类行为。)
  • 如果原有一个类本来所有的构造函数的形参中都没有一个具备initializer_list类型。而你添加一个带有std::initializer_list类型的构造函数,那就有可能造成原有使用大括号初始化的代码决议到一个新的函数中去。

成员函数说明符

default 和 delete 成员函数

在类的定义时,C++有一些规则决定是否生成默认的特殊成员函数。这些特殊成员函数可能包括:

  • 默认构造函数
  • 析构函数
  • 拷贝构造函数
  • 移动构造函数
  • 拷贝复制函数
  • 移动赋值函数

生成这些特殊成员函数的规则确实比较复杂[7],并且每个特殊成员函数可能有不同的状态,

  • 隐式声明还是用户声明
  • 默认提供还是用户提供
  • 正常状态还是删除状态 在引入default和delete说明符我们就可以明确的告诉编译器以达到我们预期的目的:
class Widget {
   ...
   Widget(const Widget&) = delete;
   Widget& operator=(const Widget&) = delete;
   ...
};

在C++11之前,我们在实现单例时候会用在private段里声明这些成员函数的方法来达到类似的目的。但目前这个语法效果更好,可以产生更明确的错误信息。另外,用户声明成删除也是一种声明,因此编译器不会提供默认版本的移动构造函数和移动赋值函数。
此外delete可以删除任何函数,包括非成员函数和模板实现(C++98声明为private做不到的)。

C++的C渊源决定了吧可以凑合看作是数值的类型,都可以隐式转型到int,如果我们直接收int为入参的话,我们可以通过阻止其他类型的调用来实现。

bool isLucky(int number); // 幸运数字
bool isLucky(char) = delete; //拒绝char类型
bool isLucky(bool) = delete; // 拒绝bool类型
bool isLucky(double) = delete; // 拒绝double和float类型

对于模板推导也可以拒绝那些不应该进行的模板具现

template<typename T>
void processPointer(T* ptr);
...
template<typename T>
void processPointer<void *>(void*) = delete;
template<typename T>
void processPointer<char *>(char*) = delete;

override 和 final 说明符

override 和 final 是两个 C++11 引入的新说明符。它们不是关键词,仅在出现在函数声明尾部时起作用,不影响我们使用这两个词作变量名等其他用途。这两个说明符可以单个或组合使用,都是加在类成员函数声明的尾部。

override 显式声明了成员函数是一个虚函数且覆盖了基类中的该函数。如果有 override 声明的函数不是虚函数,或基类中不存在这个虚函数,编译器会报告错误。这个说明符的主要作用有两个:

  • 给开发人员更明确的提示,这个函数覆写了基类的成员函数;
  • 让编译器进行额外的检查,防止程序员由于拼写错误或代码改动没有让基类和派生类中的成员函数名称完全一致。

final 则声明了成员函数是一个虚函数,且该虚函数不可在派生类中被覆盖。如果有一点没有得到满足的话,编译器就会报错。

final 还有一个作用是标志某个类或结构不可被派生。同样,这时应将其放在被定义的类或结构名后面。

参考文档

[1]维基百科,“依赖于实参的名字查找”

https://zh.wikipedia.org/zh-cn/%E4%BE%9D%E8%B5%96%E4%BA%8E%E5%AE%9E%E5%8F%82%E7%9A%84%E5%90%8D%E5%AD%97%E6%9F%A5%E6%89%BE

https://en.wikipedia.org/wiki/Argument-dependent_name_lookup

[2]维基百科,“闭包”

https://zh.wikipedia.org/wiki/%E9%97%AD%E5%8C%85_(%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%A7%91%E5%AD%A6)

[3]《Effective Modern C++》

[4]cppreference 类模板实参推导

https://zh.cppreference.com/w/cpp/language/class_template_argument_deduction

[5]cppreference 结构化绑定

https://zh.cppreference.com/w/cpp/language/structured_binding

https://en.cppreference.com/w/cpp/language/structured_binding

[6]cppreference std::initializer_list

https://en.cppreference.com/w/cpp/utility/initializer_list

[7]cppreference “特殊成员函数”部分

https://zh.cppreference.com/w/cpp/language/member_functions

https://en.cppreference.com/w/cpp/language/member_functions

  • 1
    点赞
  • 0
    评论
  • 0
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

©️2021 CSDN 皮肤主题: 代码科技 设计师:Amelia_0503 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值