类型与对象(二)

1.4 类型推导

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

  从库开发者角度来说,可以直接获取表达式的类型,写出一些曾经难以表达的代码,从用户角度来说,无需编写明显冗余的类型,从而提高了程序的可读性,本节将带领读者掌握这几种类型推导机制。

>>1.4.1 auto类型推导

  C++98的auto关键字曾用于声明一个变量为自动生命周期,与之对应的由全局静态生命周期,动态内存生命周期。因为声明一个变量时,默认生命周期就是自动的,因此该关键字无意义。从C++11起这个关键字的语义发生了变化,它被用于推导一个标识符的类型。

  在C++98中,遍历容器的场景如下:

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

  程序员每次使用迭代器场景时,都需要声明较长的vector<int>::iterator 类型,而在C++11引入lambda表达式后,其类型甚至都无法写出,因此需要使用auto来简化程序员的工作,上述例子可以写成如下代码:

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

  编译器会对表达式auto v = expr中的expr类型进行推导,从而得到auto的最终类型:

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属性。

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

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

  原因是在auto语义下,其表现为值语义,即通过移动,拷贝构造,自然就丢失了cv属性。如果想保留引用语义与cv属性,那么需要显示指定auto&。

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

  关于引用还有一个表现形式是auto&&,这可能被误认为一个右值引用类型,其实他是一个转发引用,既能绑定左值也能绑定右值表达式,在前面的1.3.3节中介绍过。

auto&& v12 = foo;   // Foo&
auto&& v13 = Foo{};  // Foo&&

  auto的类型推导语义与模板函数中的类型参数等价,即下面例子中的T与auto等价。

template<typename T>
void func(T arg);

  auto语义在C++11以后做类型推导,操作时可以适当使用auto来简化代码,需要注意的是,auto为值语义,因此会丢失引用性与对应的cv属性。

>> 1.4.2 decltype 类型推导

  从C++11起引入了decltype来获取表达式的类型。传统意义上,程序员能够轻松的通过类型实例化得到值,但却无法由值获得对应的类型,而decltype的出现打破了这个由值到类型的枷锁。它在元编程中也十分重要,本节将介绍decltype特性。

  根据C++标准,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 = Point{};

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&

  上述例子都是左值表达式,值得注意的是rrPt定义时为右值引用类型,而整体作为表达式使用则表现为左值,在1.3.1节我们也提到了这点。++pt.x作为表达式来说其结果也是左值。

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

// 纯右值(不带引用)
using T9 = decltype((pt.x++)); //int
using T10 = decltype((Point{ 1,2 })); // Point
using T11 = decltype((5)); // int

  上述例子都是纯右值,我们可以发现,对于后置自增pt.x++来说,其结果为右值。

  最后是将亡值的情况,为了区分,其类型带两个引用。

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

  上述例子都是将亡值,我们可以发现通过静态类型转换、std::move将一个左值表达式转化为将亡值。

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{12,12}.x); // int

  上述例子都能反应出来值定义时的类型,值得注意的是,rrPt定义时为右值引用类型,因此结果也是右值引用类型,而在带括号版本中其作为表达式使用则一直为左值类型。

  C++之所以提供这两方式,是因为每一个标识符的定义与使用,都面临着两种场合:

  1)标识符被定义时的类型。

  2)整体作为表达式使用时的值类别。

  C++编译时有两个经常使用的非求值上下文:sizeof与decltype。前者获取表达式类型的大小,后者获取表达式的类型。编译器不会为该表达式进行代码生成,两者都不会对操作数进行运算,即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,遵循pt定义时的类型
decltype((pt)) v2 = pt; // Point&,遵循pt作为表达式使用的值类别
decltype(1 + 2 + 3) v3 = 1 + 2 + 3; // int

  细心的读者会发现上述形式等号左右的表达式有所重复,这是件很繁琐的事情,因此C++14引入了decltype(auto)来代替这种场景。其中的auto为占位符,代表了等号右边的表达式,因此只需要写一遍即可。

decltype(auto) v1 = pt; // Point,无括号遵循pt定义时的类型
decltype(auto) v2 = (pt); // Point&,有括号遵循pt作为表达式使用的值类别
decltype(auto) v3 = 1 + 2 + 3; // int

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

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

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

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

// 注意返回类型应写成string, 结果为值语义
string look_up_a_string_1(){return lookup1();}

// 注意返回类型应写成string&,结果为引用语义,从而保留引用性
string& look_up_a_string_2(){return lookup2();}

  这个时候decltype(auto)派上用场,其能精确的捕捉类型,从而决定是值语义还是引用语义,因此上述代码可以写成如下形式。

// 返回值统一写成decltype(auto)
decltype(auto) look_up_a_string_1(){return lookup1();}
decltype(auto) look_up_a_string_2(){return lookup2();}

  如果说std::forward通过完美转发函数的入参类型从而保留其引用性,那么与之对应的decltype(auto)可以完美转发函数的返回类型,同时还能保留值语义或引用语义。虽然decltype(auto)非常灵活但是也必须注意返回变量的生命周期,否则很容易造成悬挂引用。

decltype(auto) look_up_a_string_1(){auto str = lookup1();return str;}
decltype(auto) look_up_a_string_2(){auto str = lookup1();return (str);}

  仔细分析上述代码可看出,两者都是将结果存储到局部变量str中,而且str的类型都是string。第一个版本不带括号,返回类型是str定义时的类型string,一切正常;第二个版本带括号,返回类型是str整体作为表达式使用的左值,因此decltype(auto)的结果是个左值引用string&的局部变量,从而导致悬挂引用。

>>1.4.4 std::declval元函数

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

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

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

   首先分析一下InvokeResultOfFunc的原型,这里使用C++11提供的uisng类型别名特性来代替以往的typedef从而提高可读性,using别名不仅能够替代所有的typedef场景,而且更强大,能够给模板类提供别名,而这也是typedef做不到的地方,如果说变量存储的是值,那么别名存储的就是类型。

  接下来考虑模板参数F,最简单的场景就是一个普通函数类型,那么当入参类型匹配时,其返回类型也就确定了,而一般场景考虑函数对象时,可能是一个lambda或用户自定义的函数对象,当入参类型不同时,其重载决议的operator()操作符也不一样,因此得到的返回类型也是不一样的。

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

  在这个场景中,若使用f('a',0)将决议第一个版本,函数返回一个double类型的值,进一步需要使用decltype从值得到其类型,因此decltype(f('a',0))得到函数对象调用后的返回值的类型double,同理,通过decltype(f(0))得到返回类型folat,这里我们只需要声明这两个函数而不需要实现,正是因为其处于非求值上下文decltype中,因此不会导致链接错误,由此我们可以初步实现如下:

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

  因为无法直接对类型进行调用,因此需要对F进行实例化得到函数对象F{},从而能够进行函数调用动作,同理,函数调用的入参也必须是对象(值)而不是类型。同时需要将每个入参类型进行实例化Args{}...,这样得到的是一个合法的函数调用语句,最终通过decltype操作符获得返回值的类型。需要注意的是实例化并不是真正在内存上构造对象,他在编译期非求值上下文中,仅仅是构造合法的语句。

  似乎一切正常,然而当类模板参数不可构造时,其没有默认构造函数,或者构造函数是私有的,那么上述实现将不可用,因为语句F{}/Args{}...无效,这时候标准库的std::declval模板函数就派上用场了,它能不受以上条件的约束而构造出对象,只要在非求值上下文中将F{}对象写成std::declval<F>()即可,将待构造的类型显示传给declval模板函数,最终的实现如下:

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

  可以简单验证一下我们之前声明的函数对象AFunctionObj.

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

  C++标准中对std::declval的定义是返回一个转发引用的对象,并只能用于诸如decltype和sizeof等非求值上下文中。至于为什么考虑一个引用类型的对象而不是直接返回一个值,主要是因为引用类型可以是非完备的类型,其上下文不需要求值,因此对类型要求也可以放松,另外转发引用可以保留左值或右值引用的属性。第二个问题是如何确保程序员仅在非求值上下文中使用?我们依次考虑这两点,来实现一个自定义的declval。

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

  这个实现很简单,只有一个函数声明,而没有定义,因为处于非求值上下文中,不需要构造对象,所以才使用declval,当程序员在非求值上下文中使用declval<T>()时获得的就是该函数返回的一个引用类型的对象,这正符合我们的要求。

  现在考虑第二个问题,如何约束程序员只在非求值上下文中使用?若我们尝试在求值环境中使用会出现declval模板函数未定义的链接错误。而这个错误并不友好,我们对其做进一步修改,添加更友好的错误信息。

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等非求值上下文中使用"); //防呆措施
}

  这次我们给函数添加了定义,不过同样没有给出返回值:而是通过静态断言static_assert将错误信息从链接错误转化成更友好的编译错误信息,静态断言的条件没有直接硬编码成false的原因是延迟实例化declval_protector模板类的时机防止无条件编译错误。当程序员错误的在非求值上下文以外的场境中使用时,便会对函数体中的语句进行检查,从而实例化的结果为假,导致静态断言失败,提示更加有好的信息,当程序员在非求值上下文中使用时,不会对函数体内的语句进行检查,即不会执行静态断言,据此约束了使用场景。

>> 1.4.5 类模板参数推导(CTAD)

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

int foo = std::max(1,2);
double bar = std::max<double>(1,2.0);

  标准库里面提供的max函数用于求两个数值类型的最大值,当两个数值类型一致时,编译器会帮我们推导出函数模板的模板参数例如,对于第一个版本,编译器会实例化版本max<int>;而第二个版本中由于两个数值类型不一致,编译器不知道应该用哪个类型,因此需要我们显示指定模板参数,从而实例化版本max<double>。

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

std::vector<int> foo{1,2,3,4};

std::pair<int,double> bar = {1,2.0};

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

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

  make_pair是个模板函数,可以自动推导出函数的两个参数类型,从而构造出最终的序对类型,对程序员来说每写一个模板类都需要考虑封装一个辅助函数来构造这个类型的对象,这是件重复的、繁琐的事情,还得考虑到转发引用与完美转发的问题。

  C++17引入的这个CTAD特性就是为了解决模板类需要显式指明模板参数的问题,而且这个特性不是很复杂,我们可以像使用普通类一样使用模板类,就像模板函数和普通函数的表现的一致性,在C++17中,std::pair{1,2.0}与std::pair<int,double>{1,2.0}等价,通过改写前面的代码,可以看到明显的区别,改写后的代码更精简。

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

  考虑用户实现一个模板类,最简单的情况下,只要模板类参数和构造函数能够对应的上,对于编译器来说没有什么歧义,构造的时候就能推导出类型。

template<typename T,typename U>
struct Pair {
    Pair();
    Pair(T, U);
    //...
};

Pair foo{ 1,2 }; //编译器能够自动推导出Pair<int,int>

  在这个例子中,在构造Pair时编译器能够正确的通过模板参数推导出其构造函数的模板参数,同时将模板参数运用于最终的模板类上,然后当使用默认方式构造Pair{}时,将触发编译器错误,因为编译器无法得知这两个模板参数,Pair{}也没有提供默认的模板参数。

  当构造函数与模版类参数无法对应时,这时候需要程序员定义一些推导规则,编译器会优先考虑推导规则。

template<typename T,typename U>
struct Pair {
    template<typename A,typename B>
    Pair(A&&, B&&);
    //...
};

Pair foo{ 1,2 }; //编译错误

  这个例子与前面稍微不同,构造函数与模板类的模板参数不一致,编译器会认为T和U、A与B是不同的类型,因此无法通过构造函数得知最终的模板类参数,这时可以通过添加推导规则来解决这个问题。

template<typename T,typename U>
Pair(T, U) -> Pair<T, U>;

  在做模板参数推导时,推导规则拥有很高的优先级,编译器会优先考虑推导规则,之后才考虑通过构造函数来推导。

  上述规则相当于告诉编译器当通过诸如Pair(T,U)的方式构造程序时,自行推导出模板类Pair<T,U>。因此Pair{1,2}会得到争取的Pair<int,int>类型,确定模板类后,并不会影响构造函数的决议过程,就和程序员显式指定模板参数一样,在这个例子中会通过转发引用版本的构造函数进一步构造。

  推导规则和函数声明的返回类型后置写法很相似,需要注意的是,推导规则前面没有auto,通过这一点来区分两者,CTAD特性与推导规则是非侵入式的,为已有的模板类添加推导规则并不会破坏已有的显式指明的代码,这也是为何标准库在引入特性之后不会破坏用户代码的原因。

  模板函数的模板参数可以在程序员使用时自动对类型进行推导,从而实例化进行调用。而在C++17之前,使用模板类需要显式指明模板参数,而在C++17后提供的CATD特性可以简化这一点,同时提供自定义推导规则以帮助编译器推导正确的类型。

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

posted @ 2023-06-30 11:27  饼干`  阅读(49)  评论(0编辑  收藏  举报