C++20新版本特性—类型与对象

第一章 类型与对象

编程语言中会有类型的概念,C++同样也拥有,而C++是属于静态类型系统,也就是说对象、变量包括常量都是在编译时就能得到对象的类型,并且确定之后是不可以在改变的。但是对象是运行时的概念,那么,为什么还需要类型呢?

1.1 类型的作用

B语言存在一个严重的问题,就是类型问题。它是按字长取址的,不适合单字节。类型,其实能够赋予数据意义,还能充当接口,对行为进行约束。例如一个人的类型,再怎么样也不可能变为动物类型。并且类型检查也能够组织很多无意义的程序,与动态类型语言相比,运行时检查出因类型而引发的错误都能在编译时被发现。能够省下很多程序调试的事件。

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

  • C++11中引入了右值引用,从而通过重新定义值类别来对表达式进行分类,解决了传统C++产生的临时对象需要进行多次拷贝的问题,引入了强枚举类型,不会让枚举值隐式转换为整形类型,约束了枚举值的域,不会因为传入给不同的枚举类型导致结果正确,也不允许不同枚举类型之间进行比较。放松了对union特性的约束。引入了auto关键字,对初始化变量类型进行推导,减轻了手写复杂类型的负担。引入了decltype特性,通过已有对象、变量获得其类型,解决了lambda表达式等难以声明的对象类型问题。引入了nullptr_t类型,避免了整数与指针类型导致的重载问题。
  • C++17中引入了optional类型来表达一个对象是否存在的概念。引入了variant作为类型安全的union,使这些类型的表达变的更容易,更正确。
  • C++20中引入了concept特性对类型做约束,尤其是应用在模板类型,并且增强了可读性。

1.3 值类别

C++11引入了移动语义来解决表达式产生的中间结果导致多余的拷贝,同时对左值和右值进行了定义。

1.3.1 理解左值与右值

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

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

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

答案是采用第一个版本的foo函数,虽然这里的变量是一个右值引用类型,但是value却是一个左值,说明左值和右值不是通过类型来决定的。简单来讲,只要能够取地址的就是左值表达式,否则,为右值表达式(包括临时对象)。

那么根据类型,可以形成几种情况:

  • 左值引用Value&,只能绑定左值表达式。
  • 右值引用Value&&,只能绑定右值表达式。
  • 左值常引用const Value&,可以绑定左、右值表达式,后续无法修改值。
  • 右值常引用const Value&&,只能绑定常量右值表达式,实际中不使用。

那么,我们可能会有疑问,为什么value作为右值表达式,却使用的是第一个版本foo函数呢,那么,如果使它调用第二个版本的foo函数。答案是可以采用(static_cast(value))。对于这个表达式来说,它可以被右值引用绑定,又具备左值的运行时多态性质,我们称其为将亡值

C++11使如何触发移动语义的呢,可以使用std::move(),来看看下面这个例子。

std::vector<int> x{1,2,3,4};
std::vector<int> y(std::move(x));

我们发现,原来的x已经变为空了,然后y却拥有了x原本的值,其实相当于是从x那偷过来。可以看以下源码来解释这个现象。

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

其实不难看出,构造移动时接管了被移动对象指向的指针,并在最后清空了被移动对象的指针为nullptr。move是显式的指明调用移动语义

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

为什么函数传参通常采用的是引用传递而不是值传递。因为引用可以减少多余的拷贝,提高运行时的效率。那为什么使用引用而不使用指针,是为了避免空指针的存在。

倘若只实现一个函数,该如何使这个函数更好用呢?

Value &版本:

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

这个代码有个问题就是,如果不需要修改v对象的内容,那么还需要手动构造一个Value对象。

接下来看看const Value版本:

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

可以看到这种方式能够让用户少写一行代码,并且传参的时候直接传入一个临时变量(右值,可以赋值给const引用),这种方式也更加友好。

最后,再看看Value&&版本:

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

从接口使用来看,没有任何区别,Value&&的方式也能够获得value对象的控制权:

  1. 作为成员函数来说也可以通过移动这个值来使用。
  2. 函数可以修改这个对象,然后将其废弃,相当于这个对象作为中间结果。

最后,看看这个Value&&的第二个场景应用:对图GNode节点进行深拷贝,返回拷贝后图的结点。

struct GNode {
    using _TCache = 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))
            );
        }
    }
    vector<GNode *> children_;
};
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)); 
}

这两个版本的唯一区别在于参数的引用类型不一样,程序的实现结果是一样的,若需要支持多个参数,那么每个参数都要重载两个版本,这样需要实现很多个重载版本,而且是毫无意义的。

C++11引入了变参模板特性解决了多参数版本的问题,将重载版本数量下降了一个数量级。而转发引用特性解决了同一个参数的不同引用类型导致多个重载问题

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

Arg&&引用类型能绑定左值和右值表达式,最终体现为左值引用或者右值引用,因此也被称为转发引用。若Arg&&中的Arg为模板参数或者auto,那么就是转发引用;若为具体类型,则为右值引用。

这里的arg参数的值类别是左值。若函数为return UniquePtr ( 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),其实最终结果就是为了保持左值或者右值性质。

最后,可以配合可变模板参数,实现重载版本为1。

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

这里的typename...Args声明了一个可变模板参数包Args,当...出现在标识符的后边,相当于是进行了解包操作,因此上述代码与如下代码等价:

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

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个重载版本...

1.4 类型推导

C++强大的类型系统在C++98时代仅有一种类型推导机制,那就是模板参数。C++11新增加了两个关键字autodecltype。C++14新增了关键字decltype(auto)用于简化某些推导场景。C++17提供了类模板参数推导特性,让程序员能够自定义模板类的推导规则,用户使用时无需显式指定类模板参数。

1.4.1 auto类型推导

C++98和C语言的auto关键字曾用于声明一个变量为自动生命周期。声明一个变量默认的生命周期就是自动的,因此这个关键字几乎无意义。从C++11开始,这个关键字的语义发生了变化,它被用于推导一个标识符的类型。

C++98中,遍历容器:

for(vector<int>::iterator it = vec.begin();it != vec.end();++it)
{
    /* ... */
}

程序员每当遍历这个容器的时候,都需要声明很长的类型,而在C++11引入了lambda表达式,甚至连类型都无法写出,因此需要使用auto来简化代码,因此,可以写成如下代码:

for(auto it = vec.begin();it != vec.end();++it)
{
	/* ... */
}

编译器会对表达式auto v = exprexpr进行推导,从而获得v的类型。

struct Foo {};
Foo* getPFoo();
const Foo* getCPFoo();

Foo foo{};
Foo* pfoo = &foo;
const Foo* cpfoo = &foo;

auto v1 = foo; // Foo
auto v2 = pfoo; // Foo*
auto v3 = cpfoo; // const Foo*
auto v4 = getPFoo(); // Foo*
auto v5 = getCPFoo(); // const Foo*

上面例子很好的诠释了auto的行为,但如果expr是引用或者拥有cv属性(const/volatile),那么auto推导之后会丢失。

Foo& lrfoo = foo;
const Foo& clrfoo = foo;
Foo&& rrfoo = Foo{};

auto v6 = lrfoo; // Foo
auto v7 = clrfoo; // Foo
auto v8 = rrfoo; // Foo

原因是在auto语义下,其表现为值语义,通过移动、拷贝构造,自动丢失了cv属性。如果要保留这些属性,那么需要显式指定auto&

auto& v9 = lrfoo; // Foo&
const auto& v10 = lrfoo; // const Foo&
auto& v11 = clrfoo; // const Foo&

关于引用还有一个表现形式就是auto&&,但可能会被误认为是一个右值引用类型,其实它是一个转发引用,既能绑定左值也能绑定右值。

auto&& v12 = foo; // Foo&
auto&& v13 = Foo{}; // Foo&&
1.4.2 decltype类型推导

C++11引入了decltype来获得表达式的类型。传统意义上,程序员可以通过类的实例化来获得值,但无法通过值来获得对应类型。而这个特性打破了又值获得类型的枷锁。它主要应用在两种使用场景:

场景一:若实参为无括号的标识表达式无括号的类成员访问表达式,则decltype产生以此表达式命名的实体的类型。若无这种实体或该实参指明的某个重载函数,则程序并非良构。

场景二:若实参是其他类型为T的任何表达式,且

  • 若表达式的类别为将亡值,则decltype产生T&&
  • 若表达式的类别为左值,则decltype产生T&
  • 若表达式的类别为右值,则decltype产生T

简而言之,提供了两个版本,不带括号的标识符和带括号的表达式,为方便理解,参考以下案例:

struct Point
{
    int x = 0;
    int y = 0;
};

Point pt;
Point* pPt = &pt;
const Point* cpPt = &pt;
Point& lrPt = pt;
Point&& rrPt = {};
1. 带括号的版本

带括号的版本获得表达式的值类别,我们先来看看左值表达式的情况,其类型带一个引用。

// 左值
using T1 = decltype((pt));     // Point&
using T2 = decltype((pPt));   // Point* &
using T3 = decltype((cpPt));  // const Point* &
using T4 = decltype((lrPt));   // Point&
using T5 = decltype((rrPt));   // Point&
using T6 = decltype((rrPt.x)); // int&
using T7 = decltype((pt.x));   // int&
using T8 = decltype((++pt.x)); // int&

我们来观察上面的结果,在decltype内求带括号的版本,都会自动加上一个引用,即使是右值引用类型,整体作为表达式也表现为左值。

下面再来看看纯右值表达式的情况,为了便于区分,其类型不带引用。

// 纯右值
using T9 = decltype((pt.x++)); // int
using T10 = decltype((Point(1,2))); // Point
using T11 = decltype((5)); // int

对于后置++的表达式来说,因为其在后置自增的时候,中间使用了临时变量,所以该表达式为右值表达式。

最后是将亡值,其类型带两个引用。

// 将亡值
using T12 = decltype((Point{10,10}.x)); // int&&
using T13 = decltype((std::move(pt))); // Point&&
using T14 = decltype((static_cast<Point&&>(pt))); // Point&&

将亡值的带括号版本带两个引用。

2. 不带括号的版本

不带括号的版本用于获得标识符的类型,换句话说就是获取标识符定义的类型。

using T1 = decltype(pt);        // Point
using T2 = decltype(pPt);      // Point*
using T3 = decltype(cpPt);     // const Point*
using T4 = decltype(lrPt);      // Point&
using T5 = decltype(rrPt);     // Point&&
using T6 = decltype(rrPt.x);   // int
using T7 = decltype(Point{10,10}.x); // int

注意,不带括号的版本是获得其定义时的类型,对于rrPt来说,其定义时为右值引用类型,因此结果也是。

还有一个要注意的是,sizeof(x++)decltype(x++)通常是获取值的上下文,不会对表达式进行代码生成,因此不会导致x自增。

1.4.3 decltype(auto)类型推导

有了上述两种推导方式,但C++14还提供了decltype(auto)用于类型推导,这是因为,在某些场景下仍有某些类型推导无法满足。

首先考虑auto的方式,其表现为值语义从而丢失引用性和cv属性。若是指明了const属性,则结果始终为const;若采用引用的方式,还需要显式指明auto&auto&&

考虑decltype的方式,通过表达式或者标识符来获取对应的类型,参考以下例子:

decltype(pt) v1 = pt;  // Point
decltype((pt)) v2 = pt; // Point&
decltype(1+2+3+4) v3 = 1+2+3+4; //int

但我们发现,如果采用这种方式的话,等号两边的表达式有所重复,因此,C++14引入了decltype(auto)来替代这种场景,其中auto为占位符。

decltype(auto) v1 = pt;  // Point
decltype(auto) v2 = (pt); // Point&
decltype(auto) v3 = 1+2+3+4; //int

需要注意的是等号右边通过括号来区分decltype的两种能力。

另一个场景是用于函数返回值的类型推导,考虑如下两个版本的查找函数lookup

string lookup1(); // 返回一个值的版本
string& lookup2(); // 返回一个引用的版本

若程序员想要精确返回lookup的返回值,记得函数定义时的类型,以确定是否需要保留引用性。

string look_up_a_string1() { return lookup1(); }
string& look_up_a_string2() { return lookup2(); }

可以写成如下形式:

decltype(auto) look_up_a_string1() { return lookup1(); }
decltype(auto) look_up_a_string2() { return lookup2(); }

上述代码还要注意一个问题,因为返回的时栈上局部变量的值,返回引用会导致悬挂引用

1.4.4 std::declval元函数

std::declval并不是语言的特性,而是C++11标准库提供的一个模板函数,用于在编译时非求值上下文(sizeof和decltype)对类型进行实例化得到对象,从而通过对象获得其相关信息。

考虑一个典型的场景,如何获取给定任意一个函数与其参数进行调用得到的返回类型?

template<typename F,typename... Args>
using InvokeResultOfFunc = // 如何实现?

首先分析一下InvokeResultOfFunc的原型,这里使用C++11提供的using类型别名来替代typedef从而提供可读性。接下来考虑模板参数F。最简单的场景是一个普通函数类型,当入参类型匹配时,其返回类型也能够确定。假设有一个仿函数,带有两个重载版本:

struct AFunctionObj
{
	double operator() (char,int); // #1
	float operator()(int); // #2
}f;

在这个场景中,若使用f('a',0)将决议第一个版本,函数返回一个double类型的值,这里可以通过decltype(f('a',0))来获取函数对象调用后返回值的类型,同理可以对decltype(f(0))获得返回类型float。由此,我们可以初步实现为以下版本:

template<typename F,typename... Args>
using InvokeResultOfFunc = decltype(F{}(Args{}...));

通过对F进行实例化得到函数对象,从而能够进行函数调用,如何对传入参数进行实例化,最后在通过decltype来获取返回值的类型。

看似,这段代码已经能够实现我们想要的效果,但是当类模板参数不可构造的时候,就是没有默认的构造函数,或者构造函数是私有的,那么上述实现将不可用。这时候标准库的std::declval模板函数就派上用场了,它能够不受以上条件的约束构造出对象,只要改写成std::declval()就能够构造F对象,最终实现如下。

template<typename F,typename... Args>
using InvokeResultOfFunc = decltype(std::declval<F>()(std::declval<Args>()...));

可以简单验证直接声明的函数对象:

using T1 = InvokeResultOfFunc<AFunctionObj, char, int>; // double
using T2 = InvokeResultOfFunc<AFunctionObj, int>;		// float

C++标准中对std::declval的定义是返回一个转发引用的对象,并只能用于decltype和sizeof等非求值上下文中。我们来实现一个自己定义的declval

template<typename T>
T&& declval();

这个实现很简单,只有一个函数声明,而没有定义,因为处于非求值的上下文,不需要构造对象,所以才使用declval。考虑以下问题,如何约束程序员在非求值上下文中使用呢?

struct AFunctionObj
{
	double operator() (char,int); // #1
	float operator()(int); // #2
};

int main()
{
	declval<AFunctionObj>();
}

我们来进一步修改,添加友好的错误信息。

template<typename T>
struct declval_protector
{
	static constexpr bool value = false;
};

template<typename T>
T&& declval()
{
	static_assert( declval_protector<T>::value,
					"declval应该decltype/sizeof等非求值上下文中使用!" );	
}

静态断言没有硬编码为false的原因是延迟实例化declval_protector模板类的时机防止无条件编译错误,当程序员在非求值上下文以外的场景中实现,会导致实例化结果为假,从而断言失败,提示友好的信息。当程序员是在非求值上下文的场景中实现,不会对函数体内的语句进行检查,也就不会执行静态断言。

1.4.5 类模板参数推导(CTAD)

类模板参数推导是C++17进一步引入的减少冗余代码的重要特性。当我们使用标准库里提供的模板函数时,获取不知道它是一个模板函数,但使用起来和普通函数一样自然。

int foo = std::max(1,2); // #1 省略了模板参数的指明
double bar = std::max<double>(1,2.0); // #2 指明模板参数

标准库提供的max函数用于求两个数值类型的最大值,当两个数值类型一样时,编译器会自动帮我们推导出函数模板的模板参数,例如第一个版本中,编译器会实例化max。而第二个版本中由于两个数值类型不一致,编译器不知道使用哪个类型,所以需要显式实例化max

然而,我们在使用模板类时,都必须指明模板类的模板参数后才能使用。

std::vector<int> foo{1,2,3,4};
std::pair<int,double> bar{1,2.0};

初始化一个序列类型时,我们明显可以通过构造传递的两个值类型推断出pair,但是在C++17之前,尽管编译器知道这些类型,仍然需要显式指明模板参数类型。标准库也发泄这一点,提供了一些辅助函数来解决这个问题。

auto bar = std::make_pair(1,2.0);

make_pair是个模板函数,可以推导出函数的两个参数类型。但是对于程序员来说,每个模板类如果都需要封装一个辅助函数来构造的话,这是件重复且烦琐的事情,还需要考虑转发引用和完美转发的问题。

C++17引入了这个CTAD特性就是为了解决模板类需要显式指明模板参数的问题。在C++17中,std::pair{1,2.0}std::pair{1,2.0}等价。

std::vector foo{1,2,3,4};
std::pair bar{1,2.0};

1.5 函数对象

一个对象只要能够像函数一样调用,那么这个对象就是函数对象,与普通函数相比更加同样,同时函数对象还可以拥有状态

函数对象是可以作为参数传递给其他函数,这种函数被称为高阶函数。在C++中不允许直接传递函数,虽然可以通过函数指针的形式传递,但与函数对象相比会产生间接调用的开销,不利于编译器优化。

1.5.1 定义函数对象

C++支持操作符重载,而类能够存储私有状态,这两个特性组合起来便能定义一个函数对象,C++98时代通常采用以下做法。

struct plus {
	int operator() (int x, int y)
	{ return x + y; }
};
cout << plus{}(2 , 3); // 5

上述操作其实使用plus{}实例化一个函数对象,并且这个类还重载了operator(),那么这个函数对象就可以像函数一样去进行调用,2和3分别是函数的参数,从而得到5。

更进一步可以将函数泛化,采用模板,使其支持其他数值类型。

template<typename T>
struct plus {
	T operator() (T x, T y)
	{ return x + y; }
};
cout << plus<int>{}(2 , 3); // 5

再实现了函数泛化之后,需要显式指明类的模板参数plus,如果需要为函数对象添加状态,可以通过成员变量来存储。下面实现一个加N的函数对象。

template<typename T>
struct plusN {
	plusN(int N) : N(N) { }
	T operator() (T x)
	{ return x + N; }
private:
	int N;
};

auto plus5 = plusN{5};
cout << plus5(2) << endl; // 7

观察从plus到plusN的过程,其实函数的行为都是一定的,唯一不同的是后者的状态确定,更确切的是函数的两个参数中一个参数确定了,也就是说其中一个参数被绑定了,程序员每次使用无须传递绑定的参数,只需要传递剩下的参数。

为了避免都要为参数绑定动作而实现一个对应的类与构造函数,标准库提供了一个高阶函数bind来简化这个过程。

using namespace std::placeholders;
auto plus5 = std::bind(plus<int>{}, 5, _1);

bind接受一个函数对象,这里需要对plus{}函数对象进行参数绑定,后续参数是待绑定的参数5,还有未提供的参数_1,这是一个来自于std::placeholders的占位符,bind最终生成一个只接受一个参数的函数对象plus5。当使用plus5(2)时,第一个参数2将被_1绑定,从而得到结果7。

也可以应用在标准库的算法中,比如,要打印容器中大于4的值。

vector<int> nums{5, 3, 2, 5, 6, 1, 7, 4};
copy_if(nums.begin(),nums.end(),
        ostream_iterator<int>(cout,","), // 标准输出流打印,并且使用逗号隔开
        bind(greater<int>{},_1,4)); // 相当于是大于4,_1相当于容器的每一个元素

copy_if接受一个输入迭代器区间和一个输出迭代器,接受一个单参的谓词函数(返回值为bool),对于输入区间,如果谓词函数为真,那么就会将结果复制到输出迭代器上。

1.5.2 lambda 表达式

C++11起提供了lambda特性,简化了程序员定义函数对象,无需定义对应的函数对象类,可以理解为就是一个临时的函数对象,下面是lambda表达式的语法:

[捕获] (形参列表) -> 后置返回类型 { 函数体 }

其中,后置返回类型是可以省略,编译器会自动推导出返回类型,形参列表也是可选的,因此最简单的写法如下[]{}

constexpr auto add = [](auto a,auto b) 
{
	return a + b;
};

我们声明了一个编译时的常量add来存储这个lambda表达式对象,C++17起lambda默认是constexpr,因此能够被一个constexpr常量所存储,在C++14起,lambda表达式的形参能够被声明为auto,表明类型由编译器根据实际调用传递的实参进行推导,因此,这是泛型lambda。

add其实本质上时一个匿名类,如下代码为一种可能

struct _lambda_10_16 {
	template<typename t0,typename t1>
	constexpr auto operator()(t0 a,t1 b) const { return a + b; }
};
constexpr const_lambda_10_16 add = _lambda_10_16{};

捕获列表,就是从外面捕获的对象,假如,我们要在lambda表达式内使用表达式外的变量,可以通过捕获来获取,如果是一个无捕获的lambda表达式,可以转换为一个函数指针。

using IntAddFunc = int (* )(int,int);
constexpr IntAddFunc iadd = [](auto a,auto b) { return a + b; };

在C++20中泛型lambda也支持以模板参数形式提供,这样就能保证两个形参类型一致。

constexpr auto add = []<typename T>(T a, T b) 
{
	return a + b;
}

在前面,我们可以通过bind操作为函数对象绑定部分参数,lambda作为函数对象自然也能够使用这种方式来做。

constexpr auto add5 = [add](auto x) { return add(x,5); };
cout << add5(2); // 7

比较bindlambda表达式,虽然前者提供了一种比较简洁的方式来对函数对象进行绑定,但毕竟它是库特性,不利于编译器优化。而lambda是语法特性,并且运行结果是constexpr,意味着编译器可以很简单的优化。

另外使用bind与其他高阶函数组合时,还需要考虑高阶函数的要求与传入的bind生成的函数对象是否一致。下面是通过lambda表达式的使用,可读性更高。

vector<int> nums{5, 3, 2, 5, 6, 1, 7, 4};
copy_if(nums.begin(),nums.end(),
        ostream_iterator<int>(cout,","), // 标准输出流打印,并且使用逗号隔开
        [](int x) { return x > 4; }); // 相当于是大于4

lambda表达式的捕获可以通过值传递也可以通过引用传递,而bind函数绑定的是值语义,若要引用传递,需要通过std::ref等函数将引用包裹为值语义在进行传递。考虑下面这个函数对象assign,将第二个参数传递给第一个参数。

constexpr auto assign = [](int &x,int y) { x = y; };
int x = 0;
auto assignX = [assign,&x](int y) { assign(x,y); };
assignX(5); // x = 5

assignX按引用捕获x,因此调用可以正确的赋值给x。而通过bind的方式,会产生意想不到的结果。

int x = 0;
auto assignX = std::bind(assign,x,_1);
assignX(5); // x = 0

需要改为:

auto assignX = std::bind(assign,std::ref(x),_1);
1.5.3 函数适配器

前文介绍的函数对象背后都有对应的类型:在C++11前需要定义一个类并实例化来表达函数对象。在C++11后使用lambda来表达函数对象,编译器自动生成一个匿名类型并实例化对象。C++11起标准库引入了std::function函数模板作为适配器,能够存储任何可调用的对象:

  • 普通函数(函数指针)
  • lambda表达式
  • bind表达式
  • 函数适配器
  • 其他函数对象,以及指向成员函数的指针

这就意味着不论函数对象的类型如何,都可以被std::function所存储,拥有同一类型,在运行时被绑定,进而实现值语义的运行时多态。

enum class Op {
    ADD, MUL
};

std::function<int(int, int)> OperationFactor(Op op) {
    switch (op) {
        case Op::ADD :
            return [](int a, int b) { return a + b; };
        case Op::MUL:
            return std::multiplies<int>{};
    }
}

function能够动态绑定两个入参int和返回值int的函数对象。

1.6 运行时多态

在软件开发中往往面临大量选择的问题,不同的编程范式拥有不同的解决方案:面向过程编程范式采用大量的if-else或者switch-case做选择,往往会导致选择这个细节散布到代码的各处。而面向对象通常采用接口类将细节屏蔽于工厂中。函数式编程采用模式匹配。

1.6.1. 运行时多态手段

C++语言作为最初的一门面向对象的编程语言,它唯一提供运行时多态时采用虚函数机制。C++进行面向对象编程涉及到抽象类、具体类与对象。

C++运行时多态机制采用继承的方式,它往往伴随着指针、引用,它们都能实现引用(指针)语言多态,统一的接口类指针、引用拥有不同的实现,并且往往涉及到动态内存分配。而C++17标准库的std::variantstd::visit,与指针、引用方式相比,能实现值语义多态

首先,先介绍面向对象的引用语义多态方式,该方式是提供统一的接口Shape隔离不同的形状,对统一接口进行操作获取不同的形状结果。

# define M_PI 3.14

struct Shape {
    virtual ~Shape() = default;

    virtual double getArea() const = 0;

    virtual double getPerimeter() const = 0;
};

struct Circle : Shape {
    Circle(double r) : r_(r) {}

    double getArea() const override { return M_PI * r_ * r_; }

    double getPerimeter() const override { return 2 * M_PI * r_; }

private:
    double r_;
};

struct Rectangle : Shape {
    Rectangle(double w, double h) : w_(w), h_(h) {}

    double getArea() const override { return w_ * h_; }

    double getPerimeter() const override { return 2 * (w_ + h_); }

private:
    double w_;
    double h_;
};

int main()
{
    unique_ptr<Shape> shape = make_unique<Circle>(2);
    cout << "shape area: " << shape->getArea() // 12.56
        << " perimeter:" << shape->getPerimeter() << endl; // 12.56

    shape = make_unique<Rectangle>(2,3);
    cout << "shape area: " << shape->getArea() // 6
         << " perimeter:" << shape->getPerimeter() << endl; // 10
}

其次,我们使用值语义的运行时多态方式。

#include 

struct Circle {
    double r;
};

double getArea(const Circle &c) { return M_PI * c.r * c.r; }

double getPerimeter(const Circle &c) { return 2 * M_PI * c.r; }

struct Rectangle {
    double w;
    double h;
};

double getArea(const Rectangle &r) { return r.w * r.h; }

double getPerimeter(const Rectangle &r) { return 2 * (r.w + r.h); }

using Shape = variant<Circle, Rectangle>;

// 统一的Shape的一系列多态行为
double getArea(const Shape &s) {
    return visit([](const auto &data) { return getArea(data); }, s);
}

double getPerimeter(const Shape &s) {
    return visit([](const auto &data) { return getPerimeter(data); }, s);
}

int main()
{
    Shape shape = Circle{2};

    cout << "shape area: " << getArea(shape) // 12.56
        << " perimeter:" << getPerimeter(shape) << endl; // 12.56

    shape = Rectangle{2,3};
    cout << "shape area: " << getArea(shape) // 6
         << " perimeter:" << getPerimeter(shape) << endl; // 10
}
1.6.2 subtype多态 vs ad-hoc多态

对于采用继承表达IsA关系的运行时多态,需要符合里氏替换原则,我们称为subtype多态。subtype采用函数委托分发方式,我们称为ad-hoc多态。

C++17起标准库引进了variant后能够更好地表达ad-hoc多态,那么这种多态方式在性能上的会不会有什么优势呢?

我们来观察两者的表现形式:

多态形式 定义 多态调用
subtype多态 Abstract* obj obj->method()
ad-hoc多态 Abstract obj method(obj)

对于subtype多态来说,需要存储统一的抽象接口指针,涉及到内存分配的问题,通常采用C++的智能指针进行管理,避免程序员忘记释放内存而导致内存泄漏的问题。

对于ad-hoc多态来说,采用的是值语义,则不需要考虑内存问题。

ad-hoc采用的是自由函数调用method(obj)的方式,C++11对begin()和end()是用来获取容器的迭代器,采用了自由函数的方式。

int main()
{
    vector<int> v;
    int a[100];
    // C++98
    sort(v.begin(), v.end());
    sort(&a[0], &a[0] + sizeof(a) / sizeof(a[0]));

    //C++11之后
    sort(begin(v), end(v));
    sort(begin(a), end(a));
}

考虑一下两者多态调用的扩展性,提到扩展性,就不得不提到开闭原则,当程序员新增一个功能的时候,尽量不修改原有的代码,这样对该代码的破坏程度最小。我们分别对两种方式进行分析。

对于subtype多态来说,扩展一个类相当容易,只需要写一个新类,通过继承的方式实现。但是对于行为的扩展就不那么友好了,会影响已有的依赖子类。

对于ad-hoc多态来说,扩展一个行为相当容易,只需要写一系列的自由函数即可,但对于类的扩展就没那么友好了。

总之,这两种方式其实是互补的,subtype对于类扩展友好,而ad-hoc多态对于行为扩展友好。对于面向对象来说,subtype多态用的多,而对于类的数量比较稳定,能够在编译时枚举出来的情况,适合ad-hoc多态。

你可能感兴趣的:(c++20,c++)