第一章 类型与对象(一)

        在编程语言中通常会有类型的概念,我们所使用的C++也不例外,其为静态类型(与之对应的是动态类型,对象的类型在运行时确定,其类型也可以动态改变)系统,所有对象、变量(包括常量)都得在编译时确定类型,并确定后该对象、变量的类型将不能改变。

  静态类型在编译时已确定,其是固定的;而对象是个运行时概念,其是灵活的。一旦程序运行后,就没什么类型的概念了。那么我们为何还需要类型呢?

1.1类型的作用

  C语言解决了B语言存在的一个严重的问题----类型问题。最初B语言是按字长取址,其运行机器也比较简单,那时候语言的唯一数据类型为word或者cell。当时还没有类型的概念,只是存储数据的一个单位罢了。直到PDP-11计算机的出现暴漏了B语言模型的不足之处。

  首先,它不适合处理单字节。需要将字节打包到cell上,而且读写时涉及重组这些cell;其次,PDP-11计算机支持浮点运算,B语言为了支持浮点运算引入特殊的操作符,而这些操作符是硬件相关的,最后是B语言对指针处理有额外的开销,指针作为数组的索引,需要运行时调整数组的下标才能被硬件所接受。从这些问题里可以看出类型系统的重要性,它可以让编译器生成正确指令以及对应数据的存储方式。

  后来类型系统的重要性更多体现在类型安全、类型检查上。类型不仅能够给数据赋予意义,还能充当接口,对行为进行约束。做一个让大量的猴子编写程序的思想实验,每个猴子每次随机敲下键盘的按键,然后编译、运行。

  如果这些猴子采用的是机器语言,那么每个字节的组合都可能被图灵机解释并执行,只不过这样的执行结果将毫无意义。而高级语言(例如C++语言)有自己的词法、语法规则,输入的字节组合能够被编译器检查,那么凭借这样的功能在运行前能拦住很多无意义的字节组合,最终可运行的程序或多或少都有意义。例如定义了一个“人”的类型,那么其对象的运行过程中不会变成狗,也不会飞。

  这里的类型检查就能阻止很多无意义的程序,与动态类型语言相比,在运行时所检查出的因类型而引发的错误都能够在编译时发现,这能节省很多程序调试的时间,软件开发过程中往往涉及重构,这些重构可以在保证功能不变的情况下,使得软件的可维护性更好。静态类型语言在重构后能及时发现类型错误,例如通过重构函数的形参类型,在编译时便能找出所有调用者,从而避免了遗漏。有些观点是在动态类型语言中通过添加大量测试来避免了因为类型问题而导致的错误,而测试本身往往是非确定性的,是个证伪的过程。当然仅通过类型系统是不可能完全保证程序的正确性,但至少在一定程度上保证程序的正确性。

1.2 现代C++中对类型处理能力的演进

  C++在演进过程中逐渐增强和扩展了对类型处理的能力。

  在C++11中引入了右值引用,从而重新定义值类别(value category)来对表达式进行分类,右值引用能够表达移动语义,解决了传统C++产生的中间临时对象需要多次拷贝的问题;引入了强枚举类型特性,约束了枚举值的域,同时不允许隐式转换为数值类型,也不允许不同枚举类型之间比较。相对普通枚举类型来说能够避免一些意外的bug;放松了对union特性的约束,使其能够与非平凡类组合,增强了实用性;引入了auto关键字,对初始化变量进行类型推导,减轻了程序员需要手写复杂类型(诸如迭代器类型)的负担;引入了decltype特性,通过已有对象、变量获取其类型,解决了难以声明一个对象的类型(诸如lambda对象类型)的问题;引入了nullptr_t类型,避免了整数类型与指针问题导致的重载歧义问题。

  在C++17中引入了optional类型来表达一个对象是否存在的概念;引入了variant作为类型安全的union,使这些类型的表达更容易、更正确。

  在C++20中引入concept特性来对类型做约束,如此无论是从代码角度上,还是从编译错误信息的角度上,都更加可读。

  由C++标准委员会维护的C++ Core Guidelines里也针对类型提出了很多有启发性的建议,同时提供GSL基础库用于支撑这些指导方针,它是相当轻量的基础库,实现了遵循零成本的抽象原则。一些建议,例如C语言风格接口void f(T*, int),需要同时传递指针与其长度信息。若用区间类型gsl::span<T>代替前者即 void f(gsl::span<T>),则会更加友好;如果能够保证函数通过指针传递的参数非空,那么与其每次都用 void f(T*),对指针进行判空,不如将接口设计成 void f(gsl::not_-null<T*>)。

1.3 值类别

  由于表达式产生的中间结果会导致多余的拷贝,因而在C++11中引入了移动语义来解决这个问题,同时对值类别的左值、右值进行重新定义。需要注意的是,值类别指的是表达式结果的类别,并不是指对象、变量或者类型的类别。

>>1.3.1 理解左值与右值

  考虑如下代码,应采用那个foo函数的重载版本?

void foo(int&); //#1
void foo(int&&) //#2

int&& value = 5;
foo(value);

  答案是采用第一个版本的foo函数,虽然这里的变量value的定义是一个右值引用类型,然而foo(value)中的表达式value却是一个左值,而不是由定义value时的类型来决定其值类别。通俗的说,可以理解成表达式若能取地址,则为左值表达式;否则,为右值表达式(包括临时对象)。

  根据引用与常量性进行组合,可以形成以下几种情况:

  * 左值引用Value&,只能绑定左值表达式,例如上述第一个版本的foo函数形参。

  * 右值引用Value&&,只能绑定右值表达式,例如上述第二个版本的foo函数形参。

  * 左值常引用const Value&,可以绑定左、右值表达式,但是后续无法修改值。

  * 右值常引用const Value&&,只能绑定常量右值表达式,实际中不使用。

  那么读者可能会有疑问,如何将value作为右值调用第二个版本的foo函数呢?通过foo(5)将匹配第二个版本,因为5是个右值表达式,能够被foo形参的右值引用绑定。答案是通过foo(static_cast<int&&>(value))做到。

  对于表达式static_cast<int&&>(value)来说他是右值表达式还是左值表达式呢?它可以被右值引用绑定,且具备左值的运行时多态性质,对于这种即具有左值的特征,同时又能初始化右值引用的情况,在C++11中将其归为将亡值。

  因此C++11将原来的右值成为纯右值(prvalue),把将亡值(xvalue)与纯右值(prvalue)统称为右值(rvalue);而把左值(lvalue)与将亡值(xvaule)称为泛左值(glvalue)。

  当程序引入了右值引用后,就可以表达移动语义。比较常见的就是拷贝语义,拷贝语义与移动语义都是将原值赋予目的值,对于目的值来说其内容都为原值的内容,唯一的区别在于拷贝语义不会修改(清理)原值的内容,而移动语义可能会。

  在C++11之前由于没有右值引用,实现部分移动语义时常常使用swap模式来表达,在这之后,若一个类实现了移动构造函数,那么当该对象为临时的或者该对象被手动std::move后将触发移动构造,我们来看看vector的例子。

// vector(vector&& other) noexcept;
std::vector<int> x{1,2,3,4};
std::vector<int> y(std::move(x)); // 在C++11之前使用swap(y,x);表达移动语义
assert(x.empty());
assert(y.size()==4);

  读者可能会好奇,vector的移动构造函数是如何实现的,从现象来看,原始数据x被移动后结构为空,而数据y得到了原始内容,这一切就像被施了魔法一样。如果我们走读标准库代码,不难发现vector的移动构造函数如下:

vector(vector&& rhs) noexcept{
    begin_ = rhs.begin_;
    end_ = rhs.end_;
    end_cap_ = rhs.end_cap_;
    rhs.begin_ = rhs.end_ = rhs.end_cap_ = nullptr;
}

   简而言之,移动构造时新的vector对象接管了被移动对象的堆指针并在最后清空(改变)了被移动对象的堆指针,所以从结果上看就是数据被移动了。std::move在这里显式的表达了移动语义,因为它将左值进行移动,如果没有这个std::move的行为,就是传统的拷贝行为。

  传统意义上的理解是,在赋值操作符的左边为左值、右边为右值,这种理解在现代C++上是错误的。希望通过这个例子,读者能够区分出什么是左值、右值、将亡值,它们都是指表达式的类别。同时了解利用右值引用做移动语义的原理,以及移动语义与拷贝语义的区别。

  后续内容中提到的对象,一般指泛左值;而提到值,一般指的是纯右值。

>>1.3.2 函数形参何时使用何种引用

  首先回答几个问题,为什么函数传参不用值传递而是用引用传递?因为引用可以减少多余的拷贝,提高运行时效率;为什么不用指针而是用引用?因为防御式编程在指针传递过程中需要层层判空,而引用从概念上避免了空指针的存在。

  达成以上共识后,引用传参就有三种选择:

  * Value&。

  * const Value&。

  * Value&&。

  倘若实现一个函数,该如何让这个函数更加好用呢?先看看Value&版本的函数:

// Vlaue&
Result f(Value& v){/*......*/}
// usage Value v
auto res = f(v);

  用这种方式定义接口,用户需要多写一行。因为要手动构造一个Value对象,传递给函数。如果不需要修改Value对象的内容的话,左值引用毫无优势,而且修改函数入参内容本就是一个糟糕的设计,这种意图通常是使函数返回一个对象,例如传统的C语言接口设计,将函数后几个参数作为出参。

  接下来看看const Value&版本的函数:

// const Value&
Result f(const Value& v){/*......*/}
// usage
auto res = f(Value{});
auto res2 = f(FuntionReturnValue());

  可以看到这种方式能够让用户少写一行对象的定义,传参的时候直接创建临时变量(也有可能来自于其它函数的返回值),实现同样的功能,这种方式使用起来比Value更加友好,通常接口设计采用这种方式。

  最后看看Value&&方式。之前我们看到其可以用于移动构造语义的场景,除此之外,还有一种用法:

// Value&&
Result f(Value&& v){/*......*/}
// usage
auto res = f(Value{});
auto res2 = f(FuntionReturnValue());

  从接口使用者角度来看,Value&&方式与const Value&方式没有区别。从f实现者角度来看Value&&方式拥有对Value对象的控制权:

  1)做为成员函数来说也可以移动这个值供将来使用,移动构造函数、移动赋值函数就是这种用法。

  2)函数可以修改这个对象,然后将其废弃,利用这一点就可以将这个对象作为中间结果,当函数结束后,这个中间结果即失效。

  Value&&第二种使用场景:对数据结构GNode进行深拷贝,返回拷贝后图的节点。

  

#include<map>
#include<vector>

struct GNode {
    using _TCache = std::map<const GNode*, GNode*>;
    GNode* clone(_TCache&& cache = {}) const
    {
        if (auto iter = cache.find(this); iter != cache.end()) {
            return iter->second;
        }
        auto node = new GNode(*this);
        cache.emplace(this, node);
        for (auto child : children) {
            node->children.emplace_back(child->clone(static_cast<_TCache&&>(cache)));
        }
        return node;
    }

    std::vector<GNode*> children;
};

  当用户使用auto cloned = graph->clone();接口进行拷贝时,无需传递参数cache,因为它是一个默认值,而默认值也是右值,因而能够被右值绑定。在进行深度优先拷贝时,将每个节点缓存到cache数据结构中。若之前创建过,则直接返回cache中的结果。

>>1.3.3 转发引用与完美转发

  C++11中引入了转发引用特性。而在没有转发引用的时代,泛型编程会面临一些问题,考虑如下代码:

template<typename T,typename Arg>
UniquePtr<T> makeUnique(const Arg& arg) {
    return UniquePtr<T>(new T(arg));
}

  makeUnique将参数arg传递给类T从而构造出T对象,再返回被UniquePtr包裹的智能指针。若T的构造函数只接受一个左值引用参数,那么将会导致编译错误,因为这个makeUnique只能传递常引用,无法被非常左值引用绑定。为了解决这个问题我们不得不重载一个左值引用的版本:

template<typename T,typename Arg>
UniquePtr<T> makeUnique(Arg& arg) {
    return UniquePtr<T>(new T(arg));
}

  这两个版本的makeUnique的唯一区别在于函数的入参引用类型不一样,程序的实现却一模一样。若需要支持多个参数,那么每个参数都得重载两个版本,这样库开发者不得不为了支持N个参数而实现2^N个重载版本,这是毫无意义的。

  C++11引入变参模板特性解决了多参数版本的问题,将重载版本数量下降了一个数量级,而转发引用的特性解决了同一个参数的不同引用类型导致的多个重载问题,回到最初的例子,在C++11后,只需要实现一个版本即可

template<typename T,typename Arg>
UniquePtr<T> makeUnique(Arg&& arg) {
    /*。。。。。。*/
}

  Arg&&引用类型能绑定左值、右值表达式,最终体现为左值引用或者右值引用,因此也被称为转发引用。先前介绍的右值引用也是&&形式,区分他们的方法是:若Arg&&中的Arg为模板参数或auto,那么就是转发引用;若为具体类型,则为右值引用。转发引用出现在类型推导环境下,能够保留类型的cv限定符与值类别。

  这里的arg参数的值类别是左值,若函数实现为 return UinquePtr<T>(new T(arg)),则由于arg表达式始终为左值,若其绑定的是右值,那么就失去了原值的属性:这将始终为左值。因此我们需要在函数传参时保持arg的左值或右值属性,这样便产生了std::forward。

template<typename T,typename Arg>
UniquePtr<T> makeUnique(Arg&& arg) {
    return UniquePtr<T>(new T(std::forward<Arg>(arg)));
}

  而std::forward的实现完美转发较为简单:static_cast<Arg&&>(arg),它将左值表达式arg强制类型转化成其传递时的类别,最终结果就是保留了左值或右值性质。

  最后配合可变参数模板特性,从而将2^N个重载版本降为1个。

template<typename T,typename... Args>
UniquePtr<T> makeUnique(Args&&... arg) {
    return UniquePtr<T>(new T(std::forward<Args>(arg))...);
}

  typename... Args声明了一个可变模板参数包Args,当... 出现在标识符后面时,对参数包进行展开,因此上述代码与如下代码等价:

template<typename T,typename Arg>
UniquePtr<T> makeUnique(Arg&& arg) {
    return UniquePtr<T>(new T(std::forward<Arg>(arg)));
}
template<typename T,typename Arg1,typename Arg2>
UniquePtr<T> makeUnique(Arg1&& arg1,Arg2&& arg2) {
    return UniquePtr<T>(new T(std::forward<Arg1>(arg1),std::forward<Arg2>(arg2)));
}

// 省略N个版本重载

  转发引用与完美转发固定搭配;可变参数模板解决函数任意多个参数从而无需手写多个重载版本。

-----------------------------本节完----------------------

posted @ 2023-06-29 22:55  饼干`  阅读(48)  评论(0编辑  收藏  举报