天地虽大,其化均也。
类型的相爱相杀
作为一门编译期的强类型语言,C++对于变量类型的要求在众多编程语言中都属独一档的存在。
在C语言与传统C++中,所有变量在声明/定义时都必须显式写明其类型。
其有力地保证了类型安全性,同时在变量类型较为有限的C语言中也较为可行——最复杂的名字也就是奇怪的内置struct
,而其常可用typedef
或各种宏等绕过:如stdio.h
中广为使用的FILE
实际上就是struct _iobuf
(MSVC中)的typedef
。
而在引入了变化极多的模板编程的现代C++中,using
/typedef
在一定程度上也能规避写完整变量名的问题,如std::string
是std::basic_string<char,std::char_traits<char>,std::allocator<char>>
的typedef
。
但无论何处都要显式、完整地声明类型也带来了两个问题:
其一,由于 nested template
class(嵌套模板类)的存在,STL中大量组件应用中需要写出的变量类型将显得极为冗长,最常见的就是迭代器家族。如在C++11之前,迭代一个std::map
的最简单的方式形如:
1 | std::map<int,std::string> mp; |
幸好那时还没有type_traits
,不然std::conditional_type
横冲直撞的场面简直不敢想
其二,尽管显式声明变量类型显式地保证了类型安全性,但在模板编程中,对于部分和上下文紧密相关的次要变量乔姆斯基“1型变量”,Context-sensitive
variable,如此做反而会极大地降低代码的可维护性、复用性和健壮性。
假设存在这样一个函数:
1
2
3
4
5
6
7// Some arbitrary interface
template<class Ty>
void func(const Ty& cont)
{
int t = cont.length();
//...
}
它的设计初衷就是任何具有length
成员函数的容器都能适配,并假定返回值可以被转化成int
且不会带来任何其它影响。
但假如cont.length()
的返回值类型发生了变化,如变为size_t
,unsigned long long
,乃至一些不应该甚至无法被转化为int
的类型,则修改起来会非常麻烦。
解决之道也很简单:由于我们并不关心t
是否一定要是int
,只要它是cont.length()
且满足一些相应语义即可,则将之声明为auto
是最佳选择。对于语义约束则可以通过concept
的上层设计实现;在一些其它场景(如迭代器的获取等)中亦可灵活使用const auto&
等自动用法。
C++11
auto
auto
在早期的C语言中也是一个关键字,声明其为自动存储周期(automatic
storage
duration),但其于C++98中即废弃提前回收关键字供后用。
目前的C/C++标准中,多数情况下,不加任何存储周期声明的变量都具备自动存储周期,但全局变量等除外;常见的其它几个存储周期声明符有
static
、thread_local
、register
(亦于C++17中废弃并不再生效)。
C++11中“回归”的auto
本质上是一个类型占位符(
placeholder type
),其表示该变量的类型声明可以“后置”至其初始化表达式后,即,根据初始化表达式推导变量类型(隐式地,亦约束了该变量必须被初始化):
1 | auto a = 3; //int |
使用auto
声明变量类型,最直接的好处是在初始化复杂类型的变量时,可不必记忆奇怪的嵌套/模板类类型名,只需关注上层的语义逻辑即可。
如C++11中,获取容器的迭代器并迭代之的语法就简洁了很多:
1 | std::map<int,std::string> mp; |
小趣事:不幸的是简化容器循环的“功绩”被同时引入的 range-for抢了:
1
2
3
4 for(auto i : mp)
{
//...
}
decltype
和auto
相配套的还有decltype
关键字,主要用法有decltype(expr)
和decltype(auto)
。
decltype(expr)
decltype(expr)
中,
- 若expr
是一个未加括号的标识符( unparenthesized
id-expression ),会给出其定义时的类型(所谓
named entity);
- 否则会根据expr
的值类型来决定解析类型。
前者会比较好理解:
1 | int x = 1, &y = x; |
注意若试图decltype
一个函数时,不能存在重载决议你又没传形参决议个棒槌:
1 | void fun(); |
后者包括了非标识符和用括号包裹的标识符。C++中加括号会使之具有“表达式”的属性,此时其引用/
cv 属性由expr
的值类型决定:
expr
为左值(lvalue)时,推导结果为左值引用;
expr
为将亡值(xvalue)时,推导结果为右值引用;
expr
为纯右值(prvalue)时,推导结果为非引用。
有关表达式属性左右值的胡搅蛮缠可戳:C++——右值引用与移动语义
1 | int x; |
decltype(auto)
还有一种特殊的类型占位符叫decltype(auto)
:用于变量类型声明时,其就是简单地将类型置为decltype(expr)
(进一步强调了auto
并非将类型置decltype(expr)
)。
1 | int x; |
返回类型推导
auto
作为类型占位符,其自然也可用于函数的返回类型推导上——其于模板编程中应用前景甚广。
C++11起支持了函数的返回值类型声明为auto
,但同时要求其必须声明
trailing return type(即“后置返回类型”),形如:
1 | auto fun() -> int; |
而C++14起引入了函数返回类型推导(return type
deduction),由此才算实现了真正的“自动”返回类型。推导的返回类型由第一个return
语句决定,且在自动推导下不同return
语句所推导出的类型应一致(不允许隐式转换等)。同时单纯的auto fun();
一类的声明也是合法的(但其定义应不晚于被调用时可见):
1 | auto fun(); //OK: The return type of fun() will be deduced upon its definition |
同时,由于lambda函数的返回类型默认也是auto
,因此其关于后置返回类型的“版本”约束与auto
返回类型的函数是一致的C++14起的lambda才叫真lambda:
1 | auto fun = [](){return 3;} //OK since C++14 ("real advanced lambda") |
auto
变式和decltype(auto)
但单以auto
作为返回类型(或变量类型声明)的“占位符”依然具有一定的局限性——auto
推导出的类型必定为非引用类型(可以有const
/volatile
属性),故诸如auto fun = [](){return std::cout;};
的代码就无法通过编译了(推导返回类型为不允许被复制构造的std::ostream
)。
此时就需要用到占位符的“变式”,以对推导出的返回类型给出更好的约束/控制,通过在前/后加以引用/const
等修饰,“诱导”auto
的类型和最终推导出的类型。
其本质和定义一个模板函数(形参类型为模板类型参数),再依靠实参推导模板类型参数的过程无异:
1 | template<class Ty> |
常见的几种变式如下:
占位符 | 推导结果 | 说明 |
---|---|---|
auto |
值 | 会视为需要复制/移动构造一个新变量(不考虑 拷贝省略(copy elision) ),若对应的复制/移动构造函数不可用则编译失败 |
auto& |
左值引用 | 不能通过“推导”绑定至一个右值上,否则编译失败 |
auto&& |
引用(左右值属性依expr 而定) |
参考 万能引用 (universal references) |
const auto& |
常左值引用 | 由于auto& 也可以推导出常左值引用(令auto 以形如const (type) 的类型置换),因此此处仅为语义约束 |
decltype(auto) |
- | - |
decltype(auto)
可以视情况推导出任意类型,推导规则等参考上文。
auto&
的说明“详解”可直接参考下述代码:
1 | auto& r1 = 1; //Not compile: cannot bind lvalue references to prvalue(auto deduces to `int`) |
即,C++初始化变量时,类型参数的推导不会触发SFINAE机制(优先推导int&
,失败后不会再尝试const int&
等,因其本来就不属于重载决议的范畴)。
注意若decltype(auto)
用作返回值类型,还会涉及return
中自动移动(automatic
move)的分析:
1 | decltype(auto) fn() |
可以发现C++23是其中
return ???
能否通过编译的一个分水岭,具体地是因为C++23彻底移除了C++11以来“打补丁”般的二次重载决议机制,同时也彻底避免了“传统”return
悬挂引用场景的出现:其皆于C++——return中具述之。当然架不住还是有return (rref)
之类的代码又能通过编译了,因此还得靠C++26继续填这个天坑
C++14+
auto
关键字在C++14及以后的应用变得更加广泛。
“自动”模板函数
将auto
(含其变式,如const auto&
、decltype(auto)
等)作为形参的类型占位符,可以简单地自动生成一个模板函数。直接上代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15void fun(auto x)
{
std::cout << x << std::endl;
}
auto add(auto a,auto b)
{
return a*2 + b;
}
fun(1); //instanciates some template function fun<int>, to void fun(int x)
//(template parameter `int` is invisiable by coder yet identifiable by compiler)
add(3.14,2); //instanciates some template function fun<double,double,int> to double fun(double a,int b)
//with the return type deduced as well
上述add
函数和下面的声明是一致的(每个类型占位符,placeholder
type
都会被一个模板类型参数代替),只是Ty1
等并不显式可见非要类型的话可以上:decltype
或者干脆回传统模板
1 | template<class Ty1,class Ty2,class Ty3> |
concepts联动:真正的“天人合一”
不难发现,上述“自动模板”缺点之一在于难以直观地约束模板类型参数是否符合条件。
C++20中,随着concepts的引入,auto
亦与之相辅相成,形成了极为简洁利落的带约束模板声明:
1 | template<class Ty> |
注:此处的auto
也可以是auto
的变式(加以
cvref
修饰),其中前置concept只约束auto
部分(只约束推导出的模板参数),其后引用的约束另计。
1 | void ling(std::formattable<char> auto& v){} |
“另计”的内涵在于,下述对ling
的第一个调用因为引用绑定问题,在模板实例化阶段依然会编译失败躲得过初一躲不过十五:
1 | ling(1); //Not compile: template type argument deduces to `int`,yet cannot bind a lvalue reference to a prvalue `1` |
Deducing this:珠联璧合
C++23引入的 Deducing this 的最明显用途之一在于,通过模板“增强”之,不必再为左右值编写极大量的匹配语法/语义的重载。
如在C++23以前,针对一个成员函数保持左/右值、常量语义可能需要&
、&&
、const &
、const &&
四个重载:
1 | class MyVector |
而其中最有趣而无趣的是,这四个函数的定义完全相同。
而如今结合auto
和deducing this
,一行代码即可解决:
1
2
3
4
5
6
7
8class MyVector
{
auto&& operator[](this auto&& self,size_t idx)
{
return self.arr[idx];
}
//...
};
结语
auto
作为类型“占位符”,在类型语义明确但语法复杂、模板编程中部分上下文关联紧密变量/返回值等场景中,其在让人如获神助的同时,亦或从另一角度保证了类型安全性;
同时其 cvref 和 decltype(auto)
的变式,以及模板中的
concepts “即时”约束使其应用场景更加灵活多样。
作为C++11以来极深远地改变编程风格的“小”特性,auto
亦可谓现代C++的通天塔,伴随各位对现代C++的精通之途:
从最初接触时感受如部分解释型语言点名Python般auto x = 1;
自动初始化的特性,到高阶模板编程中对左右值语义的灵活约束。