类型转换、类型别名和类型推导
1. 隐式类型转换
从不同类型的值生成某种类型的新值的过程称为转换。可以通过以下两种方式之一调用类型转换:隐式(根据编译器的需要)或显式(当程序员请求时)。
当需要一种数据类型,但提供了另一种数据类型时,编译器会自动执行隐式类型转换(也称为自动类型转换或强制转换)。C++ 中的绝大多数类型转换都是隐式类型转换。例如,隐式类型转换发生在以下所有情况下:
-
初始化具有不同数据类型值的变量(或为其赋值)时:
1
2double d{ 3 }; // int value 3 implicitly converted to type double
d = 6; // int value 6 implicitly converted to type double -
当返回值的类型与函数声明的返回类型不同时:
1
2
3
4float doSomething()
{
return 3.0; // double value 3.0 implicitly converted to type float
} -
将某些二元运算符与不同类型的操作数一起使用时:
1
double division{ 4.0 / 3 }; // int value 3 implicitly converted to type double
-
在 if 语句中使用非布尔值时:
1
2
3if (5) // int value 5 implicitly converted to type bool
{
} -
当传递给函数的参数与函数参数的类型不同时:
1
2
3
4
5void doSomething(long l)
{
}
doSomething(3); // int value 3 implicitly converted to type long
调用类型转换(无论是隐式还是显式)时,编译器将确定它是否可以将值从当前类型转换为所需类型。如果可以找到有效的转换,则编译器将生成所需类型的新值。如果编译器找不到可接受的转换,则编译将失败并出现编译错误。类型转换失败的原因有很多。例如,编译器可能不知道如何在原始类型和所需类型之间转换值。在其他情况下,语句可能不允许某些类型的转换。例如:
1 | int x { 3.5 }; // brace-initialization disallows conversions that result in data loss |
C++语言标准定义了不同的基本类型(在某些情况下,还包括复合类型)如何转换为其他类型。这些转换规则被称为标准转换(standard conversions)。
-
数值提升
-
数值转换
-
算术转换
-
其他转换
2. 浮点和整型提升
数据类型使用的位数称为其宽度。较宽的数据类型是使用更多位的数据类型,而较窄的数据类型是使用较少位的数据类型。由于 C++ 设计为在各种体系结构中具有可移植性和高性能,因此语言设计者不希望假设给定的 CPU 能够有效地处理比该 CPU 的自然数据大小窄的值。为了帮助解决这一挑战,C++ 定义了一类类型转换,非正式地称为数值提升。数值提升是将某些较窄的数值类型(如 char
)转换为某些可以有效处理的较宽数值类型(通常为 int
或 double
)的类型转换。所有数值提升都是保值的。保值转化(也称为安全转化)是指每个可能的来源值都可以转换为目标类型的相等值的转化。
数值提升也解决了另一个问题。考虑一下你想要编写一个函数来打印 int
类型的值的情况:
1 |
|
虽然这很简单,但如果我们还希望能够打印 short
类型或 char
类型的值,会发生什么情况呢?如果类型转换不存在,我们将不得不为 short
编写一个不同的 print 函数,并为 char
编写另一个 print 函数。别忘了 unsigned char
、signed char
、unsigned short
、wchar_t
、char8_t
、char16_t
和 char32_t
的另一个版本!你可以看到这很快就会变得无法控制。数值提升在这里派上用场:我们可以编写具有 int
或 double
参数的函数(例如上面的 printInt()
函数)。然后,可以使用类型的参数调用相同的代码,这些参数可以通过数值提升以匹配函数参数的类型。
数值提升规则分为两个子类别:整型提升和浮点提升。只有这些类别中列出的转化才会被视为数字提升。
2.1 浮点提升
使用浮点提升规则,可以将 float
类型的值转换为 double
类型的值。这意味着我们可以编写一个接受 double
的函数,然后使用 double
或 float
值调用它:
1 |
|
2.2 整型提升
整型提升规则更为复杂。使用整型提升规则,可以进行以下转换:
-
signed char
或signed short
可以转换为int
。 -
如果
int
可以保存类型的整个范围,则可以将unsigned char
、char8_t
和unsigned short
转换为int
,否则可以转换为unsigned int
。 -
如果
char
默认为signed
,则它遵循上述signed char
转换规则。如果默认为unsigned
,则遵循上述unsigned char
转换规则。 -
bool
可以转换为int
,其中 false 变为 0,true 变为 1。
假设一个 8 位字节和一个 4 字节或更大的 int
大小(这在当今很常见),以上基本上意味着 bool
、char
、signed char
、unsigned char
、signed short
和 unsigned short
都被提升为 int
。
还有一些其他整型规则不太常用。这些可以在 https://en.cppreference.com/w/cpp/language/implicit_conversion#Integral_promotion 中找到。
在大多数情况下,这让我们可以编写一个带有 int
参数的函数,然后将其与各种其他整型一起使用。例如:
1 |
|
这里有两件事值得注意。首先,在某些架构上(例如,使用 2 字节 int
),某些 unsigned
整数类型可能会提升为 unsigned int
而不是 int
。其次,一些较窄的无符号类型(如 unsigned char
)可能会提升为更大的有符号类型(如 int
)。因此,虽然整型提升是保值的,但它不一定保留类型的符号 (signed/unsigned)。
某些扩大类型转换(如 char
到 short
或 int
到 long
)在 C++ 中不被视为数值提升(它们是数值转换)。这是因为此类转换无助于实现将较小类型转换为可以更高效处理的较大类型的目标。
3. 数值转换
C++ 支持另一类数值类型转换,称为数值转换。这些数值转换涵盖基本类型之间的其他类型转换。数值转换有五种基本类型:
-
将整型转换为任何其他整型类型(不包括整型提升):
1
2
3
4short s = 3; // convert int to short
long l = 3; // convert int to long
char ch = s; // convert short to char
unsigned int u = 3; // convert int to unsigned int -
将浮点类型转换为任何其他浮点类型(不包括浮点提升):
1
2float f = 3.0; // convert double to float
long double ld = 3.0; // convert double to long double -
将浮点类型转换为任何整型:
1
2int i = 3.5; // convert double to int
double d = 3; // convert int to double -
将整型或浮点类型转换为 bool:
1
2bool b1 = 3; // convert int to bool
bool b2 = 3.0; // convert double to bool
与数值提升(始终保值,因此“安全”)不同,许多数值转换是不安全的。不安全转换是指源类型的至少一个值无法转换为目标类型的相等值的转换。数值转换分为三个一般安全类别:
-
值保留转换是安全的数值转换,其中目标类型可以准确表示源类型中所有可能的值。例如,
int
到long
和short
到double
是安全的转换,因为源值始终可以转换为目标类型的相等值。1
2
3
4
5
6
7
8
9
10int main()
{
int n { 5 };
long l = n; // okay, produces long value 5
short s { 5 };
double d = s; // okay, produces double value 5.0
return 0;
}编译器通常不会针对隐式保留值的转换发出警告。使用值保留转化转换的值始终可以转换回源类型,从而生成与原始值等效的值:
1
2
3
4
5
6
7
8
9
10
11
12
int main()
{
int n = static_cast<int>(static_cast<long>(3)); // convert int 3 to long and back
std::cout << n << '\n'; // prints 3
char c = static_cast<char>(static_cast<double>('c')); // convert 'c' to double and back
std::cout << c << '\n'; // prints 'c'
return 0;
} -
重新解释转换是不安全的数值转换,其中转换后的值可能与源值不同,但不会丢失任何数据。signed/unsigned 的转换属于此类别。例如,将
signed int
转换为unsigned int
时:1
2
3
4
5
6
7
8
9
10int main()
{
int n1 { 5 };
unsigned int u1 { n1 }; // okay: will be converted to unsigned int 5 (value preserved)
int n2 { -5 };
unsigned int u2 { n2 }; // bad: will result in large integer outside range of signed int
return 0;
}对于
u1
,signed int
值5
将转换为unsigned int
值5
。因此,在这种情况下,该值将被保留。对于u2
,signed int
值-5
将转换为unsigned int
。由于unsigned int
不能表示负数,结果将通过模运算被封装为超出signed int
范围的大整数值。在这种情况下,不会保留该值。尽管重新解释转换不安全,但大多数编译器默认禁用隐式有符号/无符号转换警告。这是因为在现代 C++ 的某些领域(例如使用标准库数组时),有符号/无符号转换可能很难避免。实际上,大多数此类转化实际上并不会导致价值变化。因此,启用此类警告可能会导致许多实际上正常的有符号/无符号转换出现虚假警告(淹没合法警告)。如果您选择禁用此类警告,请格外小心这些类型之间的无意转换(尤其是在将参数传递给采用相反符号的参数的函数时)。
使用重新解释转换转换的值可以转换回源类型,从而产生与原始值等效的值(即使初始转换产生的值超出源类型的范围)。因此,重新解释转换不会在转换过程中丢失数据。
1
2
3
4
5
6
7
8
9
int main()
{
int u = static_cast<int>(static_cast<unsigned int>(-5)); // convert '-5' to unsigned and back
std::cout << u << '\n'; // prints -5
return 0;
} -
有损转换是不安全的数值转换,在转换过程中可能会丢失数据。例如,
double
到int
是可能导致数据丢失的转换:1
2int i = 3.0; // okay: will be converted to int value 3 (value preserved)
int j = 3.5; // data lost: will be converted to int value 3 (fractional value 0.5 lost)从
double
到float
的转换也可能导致数据丢失:1
2float f = 1.2; // okay: will be converted to float value 1.2 (value preserved)
float g = 1.23456789; // data lost: will be converted to float 1.23457 (precision lost)将丢失数据的值转换回源类型将导致值与原始值不同:
1
2
3
4
5
6
7
8
9
10
11
12
int main()
{
double d { static_cast<double>(static_cast<int>(3.5)) }; // convert double 3.5 to int and back
std::cout << d << '\n'; // prints 3
double d2 { static_cast<double>(static_cast<float>(1.23456789)) }; // convert double 1.23456789 to float and back
std::cout << d2 << '\n'; // prints 1.23457
return 0;
}根据平台的不同,某些转化可能分为不同的类别。例如,
int
到double
通常是一种安全的转换,因为int
通常是 4 个字节,double
通常是 8 个字节,在这样的系统上,所有可能的int
值都可以表示为double
。但是,有些架构的int
和double
都是 8 字节。在这样的架构上,int
到double
是一种有损转换!
数值转换的具体规则复杂而众多,因此以下是需要记住的最重要的事情:
-
在所有情况下,将值转换为其范围不支持该值的类型都会导致可能出乎意料的结果。例如:
1
2
3
4
5
6
7
8
9int main()
{
int i{ 30000 };
char c = i; // chars have range -128 to 127
std::cout << static_cast<int>(c) << '\n';
return 0;
}在此示例中,我们为类型为
char
的变量分配了一个大整数(范围为 -128 到 127)。这会导致char
溢出,并产生意外结果:1
48
-
溢出对于无符号值是明确定义的,并且会为有符号值产生未定义行为。
-
从较大的整型或浮点型转换为同一族的较小类型通常可以正常工作,只要值适合较小类型的范围即可。例如:
1
2
3
4
5
6
7int i{ 2 };
short s = i; // convert from int to short
std::cout << s << '\n';
double d{ 0.1234 };
float f = d;
std::cout << f << '\n';这将产生预期的结果:
1
22
0.1234 -
对于浮点值,由于较小类型中的精度损失,可能会发生一些舍入。例如:
1
2float f = 0.123456789; // double value 0.123456789 has 9 significant digits, but float can only support about 7
std::cout << std::setprecision(9) << f << '\n'; // std::setprecision defined in iomanip header在这种情况下,我们看到了精度的损失,因为
float
无法保持与double
一样多的精度:1
0.123456791
-
从整数转换为浮点数通常只要值适合浮点类型的范围即可。例如:
1
2
3int i{ 10 };
float f = i;
std::cout << f << '\n';这将产生预期的结果:
1
10
-
只要值适合整数范围,就可以从浮点转换为整数,但任何小数值都会丢失。例如:
1
2int i = 3.5;
std::cout << i << '\n';在此示例中,小数值 (.5) 丢失,留下以下结果:
1
3
4. 缩小转换
在 C++ 中,缩小转换是一种可能不安全的数值转换,其中目标类型可能无法保存源类型的所有值。以下转换定义为缩小的:
-
从浮点类型到整型。
-
从浮点类型转换为更窄或排名更低的浮点类型,除非被转换的值是
constexpr
且在目标类型的范围内(即使目标类型没有足够的精度来存储该数字的所有有效数字)。 -
从整型到浮点类型,除非要转换的值是
constexpr
并且其值可以精确地存储在目标类型中。 -
从一个整型类型转换为另一个无法表示原始类型的所有值的整型,除非正在转换的值是
constexpr
,并且其值可以精确地存储在目标类型中。这包括较宽到较窄的整型转换,以及整型符号转换(有符号到无符号,反之亦然)。
在大多数情况下,缩小转换将导致编译器警告,但有符号/无符号转换除外(可能会也可能不会产生警告,具体取决于编译器的配置方式)。
由于它们可能不安全并且是错误的来源,因此请尽可能避免缩小转化范围。
缩小转换并不总是可以避免的 – 对于函数调用尤其如此,其中函数参数和参数可能具有不匹配的类型,需要缩小转换。在这种情况下,最好使用 static_cast
将隐式缩小转换转换为显式缩小转换。这样做有助于记录缩小转换是有意为之,并将禁止显示否则会导致的任何编译器警告或错误。例如:
1 | void someFcn(int i) |
使用大括号初始化时不允许缩小转换(这是首选此初始化形式的主要原因之一),尝试这样做将产生编译错误。如果您确实想在大括号初始化内进行缩小转换,请使用 static_cast
将缩小转换转换为显式转换:
1 | int main() |
当缩小转换的源值直到运行时才可知时,转换的结果也只能在运行时确定。在这种情况下,缩小转换是否保留值也无法在运行时之前确定。例如:
1 |
|
在上面的程序中,编译器不知道将为 n
输入什么值。调用 print(n)
时,将在此时执行从 int
到 unsigned int
的转换,结果可能是保值或不保值,具体取决于输入的 n
值。因此,启用了 signed/unsigned 警告的编译器将针对这种情况发出警告。
当缩小转换的源值是 constexpr
时,要被转换的具体值必须为编译器所知晓。在这种情况下,编译器可以自行执行转换,然后检查值是否被保留。如果值未被保留,编译器可以因错误而终止编译。如果值被保留,则该转换不被视为缩小转换(并且编译器可以将整个转换替换为转换后的结果,因为它知道这样做是安全的)。例如:
1 |
|
5. 算术转换
在C++中,某些运算符要求它们的操作数必须是相同类型。如果使用不同类型的操作数调用这些运算符之一,那么一个或两个操作数将根据一组规则隐式转换为匹配的类型,这些规则被称为通常的算术转换规则。通过通常的算术转换规则产生的匹配类型被称为操作数的公共类型。
以下运算符要求其作数为同一类型:
-
二进制算术运算符:
+
、-
、*
、/
、%
-
二元关系运算符:
<
、>
、<=
、>=
、==
、!
、=
-
二进制按位算术运算符:
&
、^
、|
-
条件运算符
?:
(不包括条件,其类型应为bool
)
重载运算符不受通常的算术转换规则的约束。
通常的算术转换规则有些复杂,因此我们将稍微简化一下。编译器有一个类型排名列表,如下所示:
-
long double
(最高等级) -
double
-
float
-
long long
-
long
-
int
(最低排名)
以下规则用于查找匹配类型:
-
步骤1:
- 如果一个操作数是整型,另一个是浮点类型,则整型操作数将转换为浮点操作数的类型(不进行整型提升)
- 否则,任何整型操作数都将进行数值提升
-
步骤2:
- 提升后,如果一个操作数有符号而另一个操作数无符号,则适用特殊规则
- 否则,具有较低等级的操作数将转换为具有较高等级的操作数的类型
具有不同符号的整型操作数的特殊匹配规则:
-
如果无符号操作数的排名大于或等于有符号操作数的排名,则有符号操作数将转换为无符号操作数的类型。
-
如果有符号操作数的类型可以表示无符号操作数类型的所有值,则无符号操作数的类型将转换为有符号操作数的类型。
-
否则,两个操作数都将转换为有符号操作数的相应无符号类型。
一些示例:
在以下示例中,我们将使用 typeid
运算符(包含在标头中)来显示表达式的结果类型。首先,让我们把一个 int
和一个 double
相加:
1 |
|
输出:
1 | int |
请注意,编译器显示的内容可能略有不同,因为
typeid.name()
输出的名称是特定于实现的。
两个short
类型相加:
1 |
|
输出:
1 | int 9 |
有符号值和无符号值的混合运算:
1 |
|
输出:
1 | unsigned int 4294967291 |
由于转换规则,int
操作数将转换为 unsigned int
。由于值 -5
超出了 unsigned int
的范围,因此我们得到了一个意想不到的结果。
6. 显式类型转换(强制转换)和 static_cast
C++提供了多种不同的类型转换运算符(更通常称为强制转换),程序员可以使用它们让编译器执行类型转换。由于强制转换是程序员的明确请求,这种类型的转换通常被称为显式类型转换(与隐式类型转换相对,隐式类型转换是编译器自动执行的类型转换)。
C++支持5种不同类型的强制转换: C 样式强制转换、static_cast
、const_cast
、dynamic_cast
和reinterpret_cast
。后四种有时被称为命名强制转换。
const_cast
和reinterpret_cast
通常应该避免使用,因为它们只在极少数情况下有用,如果使用不当可能会带来危害。
在标准 C 编程中,强制转换是通过 operator()
完成的,要转换为的类型的名称放在括号内,要转换的值放在右侧。您可能仍会看到这些用于从 C 转换而来的代码中。示例:
1 |
|
C++ 还将支持函数样式的 cast_
,这是一种 C 样式的转换,它使用更类似于函数调用的语法:
1 | double d { double(x) / y }; // convert x to a double so we get floating point division |
尽管 C 样式强制转换看起来是单个强制转换,但它实际上可以根据上下文执行各种不同的转换。这可以包括
static_cast
、const_cast
或reinterpret_cast
(我们上面提到的后两个你应该避免)。因此,C 样式强制转换有被无意中误用且未产生预期行为的风险,而改用 C++ 强制转换很容易避免这种情况。此外,由于 C 样式强制转换只是类型名称、括号和变量或值,因此它们都难以识别(使你的代码更难阅读),甚至更难搜索。避免使用 C 样式强制转换。
到目前为止,C++ 中最常用的强制转换是 static cast 运算符,可通过 static_cast
关键字访问。当我们想要将一种类型的值显式转换为另一种类型的值时,会使用 static_cast
。你之前已经看到static_cast
用于将 char
转换为 int
,以便 std::cout
将其打印为整数而不是字符:
1 |
|
以下是我们如何使用 static_cast
来解决我们在本课程的介绍中介绍的问题:
1 |
|
static_cast
有两个重要属性。首先,static_cast
提供编译时类型检查。如果我们尝试将值转换为类型,但编译器不知道如何执行该转换,我们将收到编译错误。其次,static_cast
(故意) 不如 C 样式转换强大,因为它会阻止某些类型的危险转换(例如那些需要重新解释或丢弃 const 的转换)。
编译器通常会在执行可能不安全(缩小)的隐式类型转换时发出警告。例如,请考虑以下代码段:
1 | int i { 48 }; |
将 int
( 2 或 4 字节) 转换为 char
(1 字节) 可能不安全 (因为编译器无法判断整数值是否会溢出 char
的范围),因此编译器通常会打印警告。如果我们使用列表初始化,编译器将产生错误。为了解决这个问题,我们可以使用 static_cast
将我们的int
显式转换为 char
:
1 | int i { 48 }; |
当我们这样做时,我们明确告诉编译器这个转换是有意为之的,并且我们承担后果的责任(例如,如果发生这种情况,则溢出 char
的范围)。由于此 static_cast
转换的输出是 char
类型,因此变量 ch
的初始化不会生成任何类型不匹配,因此不会生成警告或错误。
这是另一个示例,编译器通常会抱怨将 double
转换为 int
可能会导致数据丢失:
1 | int i { 100 }; |
要告诉编译器我们明确表示要执行此操作:
1 | int i { 100 }; |
7. typedef
和类型别名
在 C++ 中,using 是为现有数据类型创建别名的关键字。要创建这样的类型别名,我们使用 using
关键字,后跟类型别名的名称,后跟等号和现有数据类型。例如:
1 | using Distance = double; // define Distance as an alias for type double |
定义后,可以在需要类型的任何位置使用类型别名。例如,我们可以创建一个类型为类型别名的变量:
1 | Distance milesToDestination{ 3.4 }; // defines a variable of type double |
当编译器遇到类型别名时,它将替换别名类型。例如:
1 |
|
输出:
1 | 3.4 |
从历史上看,类型别名的命名方式并没有太多的一致性。有三种常见的命名约定:
-
以 “_t” 后缀结尾的类型别名(“_t” 是 “type” 的缩写)。标准库通常将此约定用于全局范围的类型名称(如
size_t
和nullptr_t
)。此约定继承自 C,在定义自己的类型别名(有时是其他类型)时最常用,但在现代 C++ 中已不再受欢迎。请注意,POSIX 为全局范围的类型名称保留了“_t”后缀,因此使用此约定可能会导致 POSIX 系统上的类型命名冲突。 -
键入以 “_type” 后缀结尾的别名。一些标准库类型(如
std::string
)使用此约定来命名嵌套类型别名(例如std::string::size_type
)。但是许多这样的嵌套类型别名根本不使用后缀(例如std::string::iterator
),所以这种用法充其量是不一致的。 -
不使用后缀的类型别名。
在现代 C++ 中,约定是命名你自己定义的类型别名(或任何其他类型),以大写字母开头,不使用后缀。大写字母有助于区分类型名称与变量和函数的名称(以小写字母开头),并防止它们之间的命名冲突。使用此命名约定时,通常会看到以下用法:
1 | void printDistance(Distance distance); // Distance is some defined type |
别名实际上并不定义新的、不同的类型(被认为独立于其他类型的类型)——它只是为现有类型引入了一个新的标识符。类型别名与别名类型完全可互换。因为范围是标识符的一个属性,所以类型别名标识符遵循与变量标识符相同的范围规则:在块中定义的类型别名具有块范围,并且只能在该块内使用,而在全局命名空间中定义的类型别名具有全局范围,并且可用于文件末尾。
如果需要在多个文件中使用一个或多个类型别名,则可以在头文件中定义它们,并将其 #included
到需要使用该定义的任何代码文件中:
1 |
|
以这种方式 #included
类型别名将被导入到全局命名空间中,因此具有全局范围。
typedef(是 “type definition” 的缩写)是为类型创建别名的旧方法。要创建 typedef 别名,我们使用 typedef
关键字:
1 | // The following aliases are identical |
出于向后兼容性的原因,Typedef 仍在 C++ 中,但它们在很大程度上已被现代 C++ 中的类型别名所取代。
首选类型别名而不是 typedef。
类型别名的主要用途之一是隐藏特定于平台的详细信息。由于 char
、short
、int
和 long
不指示其大小,因此跨平台程序使用类型别名来定义包含类型大小(以位为单位)的别名是相当常见的。例如,int8_t
将是 8 位有符号整数,int16_t
16 位有符号整数,int32_t
32 位有符号整数。以这种方式使用类型别名有助于防止错误,并更清楚地了解对变量大小所做的假设类型。
为了确保每个别名类型都解析为正确大小的类型,这种类型的类型别名通常与预处理器指令结合使用:
1 |
|
在整数只有 2 个字节的机器上,可以 #defined INT_2_BYTES
(作为编译器/预处理器设置),并且程序将使用顶级类型别名进行编译。在整数为 4 字节的计算机上,将 INT_2_BYTES
保留为 undefined 将导致使用底部的类型别名集。这样,只要INT_2_BYTES
#defined
正确,int8_t
将解析为 1 字节的整数,int16_t
将解析为 2 字节的整数,int32_t
将解析为 4 字节的整数(使用适合于正在编译程序的计算机的 char
、short
、int
和 long
的组合)。固定宽度的整数类型(例如 std::int16_t
和 std::uint32_t
)和 size_t
类型实际上只是各种基本类型的类型别名。
使用类型别名使复杂类型更易于阅读:
1 | using VectPairSI = std::vector<std::pair<std::string, int>>; // make VectPairSI an alias for this crazy type |
类型别名还有助于代码文档编写和理解。
1 | using TestScore = int; |
使用类型别名以便于代码维护。类型别名还允许你更改对象的底层类型,而无需更新大量硬编码类型。例如,如果你用 short
来保存学生的 ID 号,但后来决定需要一个 long
来代替,那么你必须梳理大量代码并将 short
替换为 long
。可能很难弄清楚哪些 short
类型的对象用于保存 ID 号,哪些对象用于其他目的。但是,如果使用类型别名,则更改类型将变得与更新类型别名一样简单(例如,从using StudentId = short;
到using StudentId = long;
)。
8. 使用 auto
关键字的对象进行类型推导
在这个简单的变量定义中隐藏着一个微妙的冗余:
1 | double d{ 5.0 }; |
在 C++ 中,我们需要为所有对象提供显式类型。因此,我们指定了变量 d
的类型为 double
。但是,用于初始化 d
的文本值 5.0
也具有 double
类型(通过文本的格式隐式确定)。
类型推导(有时也称为类型推断)是一项功能,它允许编译器从对象的初始值设定项中推断对象的类型。定义变量时,可以使用 auto
关键字来调用类型推导,该关键字可以代替变量的类型:
1 | int main() |
在 C++17 之前,
auto d{ 5.0 };
会推断出d
的类型是std::initializer_list<double>
,而不是double
。这在 C++17 中已修复,许多编译器(例如 gcc 和 Clang)已将此更改向后移植到以前的语言标准中。如果您使用的是 C++14 或更早版本,并且上面的示例无法在编译器上编译,请改用auto
(auto d = 5.0
) 的复制初始化。
因为函数调用是有效的表达式,所以当我们的初始化器是非 void 函数调用时,我们甚至可以使用类型推导:
1 | int add(int x, int y) |
文本后缀可以与类型推导结合使用,以指定特定类型:
1 | int main() |
使用类型推导的变量也可以使用其他说明符/限定符,例如 const
或 constexpr
:
1 | int main() |
类型推导不适用于没有初始值设定项或具有空初始值设定项的对象。当初始化器的类型为 void
(或任何其他不完整类型) 时,它也不起作用。因此,以下内容无效:
1 |
|
当类型变得复杂和冗长时,使用 auto
可以节省大量键入(和拼写错误)。
在大多数情况下,类型推导将从推导的类型中删除 const
。例如:
1 | int main() |
如果你想让一个推导的类型是 const,你必须自己提供 const
作为定义的一部分:
1 | int main() |
由于历史原因,C++ 中的字符串文本具有奇怪的类型。因此,以下作可能无法按预期工作:
1 | auto s { "Hello, world" }; // s will be type const char*, not std::string |
如果你想从字符串字面量推导出的类型是 std::string
或 std::string_view
,你需要使用 s
或 sv
字面量后缀:
1 |
|
但在这种情况下,最好不要使用类型推导。因为 constexpr
不是类型系统的一部分,所以它不能作为类型推导的一部分进行推导。但是,constexpr
变量是隐式的 const
,并且此 const
将在类型推导期间删除(如果需要,可以重新添加):
1 | int main() |
类型推导不仅方便,而且还有很多其他好处:
-
首先,如果在连续行上定义了两个或多个变量,则变量的名称将排成一行,有助于提高可读性:
1
2
3
4
5
6
7// harder to read
int a { 5 };
double b { 6.7 };
// easier to read
auto c { 5 };
auto d { 6.7 }; -
其次,类型推导仅适用于具有初始值设定项的变量,因此,如果你习惯使用类型推导,它可以帮助避免无意中未初始化的变量:
1
2int x; // oops, we forgot to initialize x, but the compiler may not complain
auto y; // the compiler will error out because it can't deduce a type for y -
第三,你可以保证不会发生影响效果的意外转化:
1
2
3
4std::string_view getString(); // some function that returns a std::string_view
std::string s1 { getString() }; // bad: expensive conversion from std::string_view to std::string (assuming you didn't want this)
auto s2 { getString() }; // good: no conversion required
类型推导也有一些缺点:
-
首先,类型推导会掩盖代码中对象的类型信息。虽然一个好的 IDE 应该能够向你展示推导的类型(例如,当悬停在一个变量上时),但在使用类型推导时仍然更容易犯基于类型的错误。例如:
1
auto y { 5 }; // oops, we wanted a double here but we accidentally provided an int literal
在上面的代码中,如果我们显式地将
y
指定为double
类型,那么即使我们不小心提供了int
文本初始化器,y
也会是一个double
。使用类型推导,y
将被推断为int
类型。这是另一个示例:1
2
3
4
5
6
7
8
9
10
11
int main()
{
auto x { 3 };
auto y { 2 };
std::cout << x / y << '\n'; // oops, we wanted floating point division here
return 0;
} -
其次,如果初始化器的类型发生变化,则使用类型推导的变量的类型也会发生变化,这可能是意想不到的。考虑:
1
auto sum { add(5, 6) + gravity };
如果
add
的返回类型从int
更改为double
,或者gravity
从int
更改为double
,则sum
也会将类型从int
更改为double
。
总的来说,现代共识是类型推导通常可以安全地用于对象,并且这样做可以通过不强调类型信息来帮助使代码更具可读性,从而使代码的逻辑更加突出。
9. 函数的类型推导
1 | int add(int x, int y) |
编译此函数时,编译器将确定 x + y
的计算结果为 int
,然后确保返回值的类型与函数的声明返回类型匹配(或者返回值类型可以转换为声明的返回类型)。
由于编译器已经需要从 return 语句中推导出返回类型(以确保该值可以转换为函数声明的返回类型),因此在 C++14 中,auto
关键字被扩展为执行函数返回类型推导。这是通过使用 auto
关键字代替函数的返回类型来实现的。例如:
1 | auto add(int x, int y) |
由于 return 语句返回 int
值,因此编译器将推断此函数的返回类型为 int
。当使用 auto
返回类型时,函数中的所有 return 语句都必须返回相同类型的值,否则将导致错误。例如:
1 | auto someFcn(bool b) |
在上面的函数中,两个 return 语句返回不同类型的值,因此编译器会给出错误。返回类型推导的最大优点是,让编译器推导函数的返回类型可以消除返回类型不匹配的风险(防止意外转换)。
这在函数的返回类型较为脆弱时(即返回类型可能因实现的更改而变化的情况)特别有用。在这种情况下,明确指定返回类型意味着在对实现进行影响性更改时,需要更新所有相关的返回类型。如果幸运的话,编译器会在我们更新相关返回类型之前报错。如果不幸运,我们可能会在不期望的地方得到隐式转换。
在其他情况下,函数的返回类型可能很长很复杂,或者不是那么明显。在这种情况下,auto
可用于简化:
1 | // let compiler determine the return type of unsigned short + char |
返回类型推导有两个主要缺点:
-
使用
auto
返回类型的函数必须先完全定义,然后才能使用(前向声明是不够的)。例如:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
auto foo();
int main()
{
std::cout << foo() << '\n'; // the compiler has only seen a forward declaration at this point
return 0;
}
auto foo()
{
return 5;
}这会产生以下编译错误:
1
error C3779: 'foo': a function that returns 'auto' cannot be used before it is defined.
-
当对对象使用类型推导时,初始化器始终作为同一语句的一部分存在,因此确定将推导的类型通常不会太麻烦。对于函数的类型推导,情况并非如此 —— 函数的原型没有指示函数实际返回的类型。一个好的编程 IDE 应该清楚地说明函数的推导类型是什么,但是在没有该类型的情况下,用户实际上必须深入研究函数体本身以确定函数返回的类型。犯错的几率更高。通常,我们更喜欢明确作为接口一部分的类型(函数的声明是一个接口)。
与返回类型推导相比,首选显式返回类型(除非返回类型不重要、难以表达或脆弱)。
auto
关键字还可用于使用尾随返回语法声明函数,其中返回类型在函数原型的其余部分之后指定。请考虑以下函数:
1 | int add(int x, int y) |
使用尾随返回语法,可以等效地写成:
1 | auto add(int x, int y) -> int |
在这种情况下,auto
不执行类型推导 —— 它只是使用尾随返回类型的语法的一部分。
为什么要使用它?以下是一些原因:
-
对于具有复杂返回类型的函数,尾随返回类型可以使函数更易于阅读:
1
2
3
4
std::common_type_t<int, double> compare(int, double); // harder to read (where is the name of the function in this mess?)
auto compare(int, double) -> std::common_type_t<int, double>; // easier to read (we don't have to read the return type unless we care) -
尾随返回类型语法可用于对齐函数的名称,这使得连续的函数声明更易于阅读:
1
2
3
4auto add(int x, int y) -> int;
auto divide(double x, double y) -> double;
auto printSomething() -> void;
auto generateSubstring(const std::string &s, int start, int len) -> std::string; -
如果我们有一个函数,其返回类型必须根据函数参数的类型进行推导,那么普通的返回类型是不够的,因为编译器在那个时候还没有看到参数:
1
2
3
4
5
// note: decltype(x) evaluates to the type of x
std::common_type_t<decltype(x), decltype(y)> add(int x, double y); // Compile error: compiler hasn't seen definitions of x and y yet
auto add(int x, double y) -> std::common_type_t<decltype(x), decltype(y)>; // ok -
C++ 的某些高级功能也需要尾随返回语法,例如 lambdas。
目前,建议继续使用传统的函数返回语法,除非需要尾随返回语法。
类型推导不能用于函数参数类型:
1 |
|
不幸的是,类型推导不适用于函数参数,并且在 C++20 之前,上述程序无法编译(你将收到有关函数参数无法具有 auto
类型的错误)。在 C++20 中,扩展了 auto
关键字,以便上述程序能够正确编译和运行——但是,在这种情况下,auto
不会调用类型推导。相反,它触发了一个称为函数模板的不同功能,该功能旨在实际处理此类情况。