1. 程序定义(用户定义)类型简介

C++ 有两种不同类别的复合类型,可用于创建程序定义的类型:

  • 枚举类型(包括非限定和限定的枚举)

  • 类类型(包括结构体、类和联合体)。

按照惯例,程序定义的类型以大写字母开头命名,并且不使用后缀(例如 Fraction、而不是 fractionfraction_tFraction_t)。示例:

1
Fraction fraction {}; // Instantiates a variable named fraction of type Fraction

类型 (Fraction) 首先出现,然后是变量名称 (fraction),然后是一个可选的初始化器。因为 C++ 区分大小写,所以这里没有命名冲突!

每个使用程序自定义类型的代码文件在使用该类型之前都需要看到完整的类型定义。仅仅使用前向声明(forward declaration)是不够的。这是因为编译器需要知道为该类型的对象分配多少内存。为了将类型定义传播到需要它们的代码文件中,通常在头文件中定义程序定义的类型,然后 #included 到需要该类型定义的任何代码文件中。这些头文件通常与程序定义的类型具有相同的名称(例如,名为 Fraction 的程序定义类型将在 Fraction.h 中定义)。

类型定义部分不受单定义规则 (ODR) 的约束,编译器通常需要查看完整定义才能使用给定类型。我们必须能够将完整的类型定义传播到需要它的每个代码文件。为了实现这一点,类型部分不受单一定义规则的约束:允许在多个代码文件中定义给定类型。

给定类型的所有类型定义必须相同,否则将导致未定义行为。

2. 非限定枚举

枚举(也称为枚举类型)是一种复合数据类型,其值仅限于一组命名的符号常量(称为枚举成员)。C++ 支持两种枚举类型:非限定枚举(unscoped enumerations)和限定枚举(scoped enumerations)。

枚举成员是隐式的 constexpr。

非限定枚举通过 enum 关键字定义。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Define a new unscoped enumeration named Color
enum Color
{
// Here are the enumerators
// These symbolic constants define all the possible values this type can hold
// Each enumerator is separated by a comma, not a semicolon
red,
green,
blue, // trailing comma optional but recommended
}; // the enum definition must end with a semicolon

int main()
{
// Define a few variables of enumerated type Color
Color apple { red }; // my apple is red
Color shirt { green }; // my shirt is green
Color cup { blue }; // my cup is blue

Color socks { white }; // error: white is not an enumerator of Color
Color hat { 2 }; // error: 2 is not an enumerator of Color

return 0;
}

你创建的每个枚举类型都被视为非重复类型,这意味着编译器可以将其与其他类型区分开来(与 typedef 或类型别名不同,它们被视为与它们别名的类型不同)。由于枚举类型是不同的,因此定义为一个枚举类型一部分的枚举成员不能与另一个枚举类型的对象一起使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
enum Pet
{
cat,
dog,
pig,
whale,
};

enum Color
{
black,
red,
blue,
};

int main()
{
Pet myPet { black }; // compile error: black is not an enumerator of Pet
Color shirt { pig }; // compile error: pig is not an enumerator of Color

return 0;
}

通常定义的枚举包括星期几、基本方向和一副纸牌中的花色:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
enum DaysOfWeek
{
sunday,
monday,
tuesday,
wednesday,
thursday,
friday,
saturday,
};

enum CardinalDirections
{
north,
east,
south,
west,
};

enum CardSuits
{
clubs,
diamonds,
hearts,
spades,
};

有时,函数会向调用者返回状态代码,以指示函数是成功执行还是遇到错误,更好的方法是使用枚举类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
enum FileReadResult
{
readResultSuccess,
readResultErrorFileOpen,
readResultErrorFileRead,
readResultErrorFileParse,
};

FileReadResult readFileContents()
{
if (!openFile())
return readResultErrorFileOpen;
if (!readFile())
return readResultErrorFileRead;
if (!parseFile())
return readResultErrorFileParse;

return readResultSuccess;
}

当用户需要在两个或多个选项之间进行选择时,枚举类型也可以成为有用的函数参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
enum SortOrder
{
alphabetical,
alphabeticalReverse,
numerical,
};

void sortData(SortOrder order)
{
switch (order)
{
case alphabetical:
// sort data in forwards alphabetical order
break;
case alphabeticalReverse:
// sort data in backwards alphabetical order
break;
case numerical:
// sort data numerically
break;
}
}

许多编程语言使用枚举来定义布尔值(Boolean)——毕竟,布尔值本质上只是一个包含两个枚举成员(false和true)的枚举类型!然而,在 C++ 中,true 和 false 被定义为关键字(keywords),而不是枚举成员(enumerators)。

由于枚举很小且复制成本低廉,因此可以按值传递 (和返回) 它们。

非限定枚举之所以这样命名,是因为它们将其枚举成员名称置于与枚举定义本身相同的范围内(而不是像命名空间那样创建新的范围区域)。例如,给定此程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
enum Color // this enum is defined in the global namespace
{
red, // so red is put into the global namespace
green,
blue,
};

int main()
{
Color apple { red }; // my apple is red

return 0;
}

枚举成员名称不能在同一范围内的多个枚举中使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
enum Color
{
red,
green,
blue, // blue is put into the global namespace
};

enum Feeling
{
happy,
tired,
blue, // error: naming collision with the above blue
};

int main()
{
Color apple { red }; // my apple is red
Feeling me { happy }; // I'm happy right now (even though my program doesn't compile)

return 0;
}

在上面的示例中,两个未作用域的枚举(ColorFeeling)都将同名 blue 的枚举成员放入全局作用域中。这会导致命名冲突和随后的编译错误。

非限定枚举还为其枚举成员提供命名范围区域(很像命名空间充当其中声明的名称的命名范围区域)。这意味着我们可以按如下方式访问非限定枚举的枚举成员:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
enum Color
{
red,
green,
blue, // blue is put into the global namespace
};

int main()
{
Color apple { red }; // okay, accessing enumerator from global namespace
Color raspberry { Color::red }; // also okay, accessing enumerator from scope of Color

return 0;
}

有很多常见的方法可以防止非限定的枚举成员命名冲突。一种选择是为每个枚举成员添加枚举本身的名称作为前缀:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
enum Color
{
color_red,
color_blue,
color_green,
};

enum Feeling
{
feeling_happy,
feeling_tired,
feeling_blue, // no longer has a naming collision with color_blue
};

int main()
{
Color paint { color_blue };
Feeling me { feeling_blue };

return 0;
}

更好的选择是将枚举类型放在提供单独范围区域的内容中,例如命名空间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
namespace Color
{
// The names Color, red, blue, and green are defined inside namespace Color
enum Color
{
red,
green,
blue,
};
}

namespace Feeling
{
enum Feeling
{
happy,
tired,
blue, // Feeling::blue doesn't collide with Color::blue
};
}

int main()
{
Color::Color paint{ Color::blue };
Feeling::Feeling me{ Feeling::blue };

return 0;
}

我们可以使用相等运算符(operator==operator!=)来测试枚举类型的值是否等于特定的枚举成员。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>

enum Color
{
red,
green,
blue,
};

int main()
{
Color shirt{ blue };

if (shirt == blue) // if the shirt is blue
std::cout << "Your shirt is blue!";
else
std::cout << "Your shirt is not blue!";

return 0;
}

3. 非限定枚举成员的整数转换

当我们定义一个枚举时,每个枚举成员都会根据它在枚举成员列表中的位置自动与一个整数值相关联。默认情况下,第一个枚举成员的整数值为 0,并且每个后续枚举成员的值都比前一个枚举成员大 1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
enum Color
{
black, // 0
red, // 1
blue, // 2
green, // 3
white, // 4
cyan, // 5
yellow, // 6
magenta, // 7
};

int main()
{
Color shirt{ blue }; // shirt actually stores integral value 2

return 0;
}

可以显式定义枚举成员的值。这些整数值可以是正值或负值,并且可以与其他枚举成员共享相同的值。任何未定义的枚举成员都会被赋予一个比前一个枚举成员大 1 的值。示例:

1
2
3
4
5
6
7
8
9
enum Animal
{
cat = -3, // values can be negative
dog, // -2
pig, // -1
horse = 5,
giraffe = 5, // shares same value as horse
chicken, // 6
};

请注意,在本例中,horsegiraffe 被赋予了相同的值。发生这种情况时,枚举成员变得非区分 - 本质上,horsegiraffe是可以互换的。尽管 C++ 允许这样做,但通常应避免将相同的值分配给同一枚举中的两个枚举成员。

如果枚举是零初始化的(当我们使用值初始化时会发生这种情况),则枚举将被赋予值 0,即使没有具有该值的相应枚举成员。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>

enum Animal
{
cat = -3, // -3
dog, // -2
pig, // -1
// note: no enumerator with value 0 in this list
horse = 5, // 5
giraffe = 5, // 5
chicken, // 6
};

int main()
{
Animal a {}; // value-initialization zero-initializes a to value 0
std::cout << a; // prints 0

return 0;
}

这有两个后果:

  • 如果存在值为 0 的枚举成员,则值初始化会将枚举默认为该枚举成员的含义。

  • 如果没有值为 0 的枚举成员,则值初始化可以轻松创建语义上无效的枚举。

使表示 0 的枚举成员成为枚举的最佳默认含义。如果不存在良好的默认含义,请考虑添加值为 0 的“无效”或“未知”枚举成员,以便显式记录该状态,并可以在适当的情况下显式处理。

即使枚举存储整型值,它们也不被视为整型(它们是复合类型)。但是,非限定枚举将隐式转换为整数值。因为枚举成员是编译时常量,所以这是一个 constexpr 转换。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>

enum Color
{
black, // assigned 0
red, // assigned 1
blue, // assigned 2
green, // assigned 3
white, // assigned 4
cyan, // assigned 5
yellow, // assigned 6
magenta, // assigned 7
};

int main()
{
Color shirt{ blue };

std::cout << "Your shirt is " << shirt << '\n'; // what does this do?

return 0;
}

这将打印:

1
Your shirt is 2

由于编译器找不到匹配项,因此它将检查 operator<< 是否知道如何打印非限定枚举转换为的整数类型的对象。

对于非限定枚举(unscoped enumerations),C++ 标准并未指定应使用哪种特定的整数类型作为其底层类型,因此该选择是由具体实现定义的。大多数编译器会使用 int 作为底层类型(这意味着非限定枚举的大小与 int 相同),除非需要更大的类型来存储枚举成员的值。但是,你不应假设在所有编译器或平台上都是如此。可以显式指定枚举的基础类型。基础类型必须是整型。例如,如果你在一些带宽敏感的上下文中工作(例如,通过网络发送数据),你可能想为枚举指定一个更小的类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <cstdint>  // for std::int8_t
#include <iostream>

// Use an 8-bit integer as the enum underlying type
enum Color : std::int8_t
{
black,
red,
blue,
};

int main()
{
Color c{ black };
std::cout << sizeof(c) << '\n'; // prints 1 (byte)

return 0;
}

虽然编译器会将非限定枚举隐式转换为整数,但它不会隐式地将整数转换为非限定枚举。以下将产生编译器错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
enum Pet // no specified base
{
cat, // assigned 0
dog, // assigned 1
pig, // assigned 2
whale, // assigned 3
};

int main()
{
Pet pet { 2 }; // compile error: integer value 2 won't implicitly convert to a Pet
pet = 3; // compile error: integer value 3 won't implicitly convert to a Pet

return 0;
}

有两种方法可以解决这个问题。首先,你可以使用 static_cast 显式地将整数转换为非限定的枚举成员:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
enum Pet // no specified base
{
cat, // assigned 0
dog, // assigned 1
pig, // assigned 2
whale, // assigned 3
};

int main()
{
Pet pet { static_cast<Pet>(2) }; // convert integer 2 to a Pet
pet = static_cast<Pet>(3); // our pig evolved into a whale!

return 0;
}

其次,从 C++17 开始,如果非限定枚举具有显式指定的基,则编译器将允许您使用整型值对非限定枚举进行列表初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
enum Pet: int // we've specified a base
{
cat, // assigned 0
dog, // assigned 1
pig, // assigned 2
whale, // assigned 3
};

int main()
{
Pet pet1 { 2 }; // ok: can brace initialize unscoped enumeration with specified base with integer (C++17)
Pet pet2 (2); // compile error: cannot direct initialize with integer
Pet pet3 = 2; // compile error: cannot copy initialize with integer

pet1 = 3; // compile error: cannot assign with integer

return 0;
}

4. 枚举与字符串相互转换

获取枚举成员名称的典型方法是编写一个函数,该函数允许我们传入枚举成员并将枚举成员的名称作为字符串返回。但这需要某种方法来确定应该为给定的枚举成员返回哪个字符串。有两种常见的方法可以做到这一点。第一种方法是通过switch语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>
#include <string_view>

enum Color
{
black,
red,
blue,
};

constexpr std::string_view getColorName(Color color)
{
switch (color)
{
case black: return "black";
case red: return "red";
case blue: return "blue";
default: return "???";
}
}

int main()
{
constexpr Color shirt{ blue };

std::cout << "Your shirt is " << getColorName(shirt) << '\n';

return 0;
}

解决将枚举成员映射到字符串的程序的第二种方法是使用数组。

现在来看输入情况,我们定义一个 Pet 枚举,但因为 Pet 是一个程序定义的类型,所以C++不知道如何使用 std::cin 输入 Pet。解决此问题的一种简单方法是读取整数,并使用 static_cast 将整数转换为适当枚举类型的枚举成员:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <iostream>
#include <string_view>

enum Pet
{
cat, // 0
dog, // 1
pig, // 2
whale, // 3
};

constexpr std::string_view getPetName(Pet pet)
{
switch (pet)
{
case cat: return "cat";
case dog: return "dog";
case pig: return "pig";
case whale: return "whale";
default: return "???";
}
}

int main()
{
std::cout << "Enter a pet (0=cat, 1=dog, 2=pig, 3=whale): ";

int input{};
std::cin >> input; // input an integer

if (input < 0 || input > 3)
std::cout << "You entered an invalid pet\n";
else
{
Pet pet{ static_cast<Pet>(input) }; // static_cast our integer to a Pet
std::cout << "You entered: " << getPetName(pet) << '\n';
}

return 0;
}

如果用户能输入一个代表枚举成员的字符串(例如 “pig”),而不是输入一个数字,那就更好了,我们可以将该字符串转换为适当的 Pet 枚举成员。但是,这样做有几个问题。首先,我们不能打开字符串,因此我们需要使用其他东西来匹配用户传入的字符串。这里最简单的方法是使用一系列 if 语句。其次,如果用户传入无效字符串,我们应该返回哪个 Pet 枚举成员?一种选择是添加一个枚举成员来表示 “none/invalid”,并返回它。但是,更好的选择是在此处使用 std::optional

请注意,上述解决方案仅匹配小写字母。如果要匹配任何字母大小写,可以使用以下函数将用户的输入转换为小写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <algorithm> // for std::transform
#include <cctype> // for std::tolower
#include <iterator> // for std::back_inserter
#include <string>
#include <string_view>

// This function returns a std::string that is the lower-case version of the std::string_view passed in.
// Only 1:1 character mapping can be performed by this function
std::string toASCIILowerCase(std::string_view sv)
{
std::string lower{};
std::transform(sv.begin(), sv.end(), std::back_inserter(lower),
[](char c)
{
return static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
});
return lower;
}

此函数遍历 std::string_view sv 中的每个字符,使用 std::tolower()(借助一个 lambda 表达式)将其转换为小写字符,然后将该小写字符追加到 lower 中。

5. 重载 I/O 运算符简介

C++除了支持函数重载,还支持运算符重载,这允许我们定义现有运算符的重载,以便我们可以使这些运算符与程序定义的数据类型一起使用。

基本的运算符重载相当简单:

  1. 使用运算符的名称作为函数的名称定义一个函数。

  2. 按照从左到右的顺序,为每个操作数添加一个合适类型的参数。这些参数中至少有一个必须是用户自定义类型(类类型或枚举类型),否则编译器会报错。

  3. 将返回类型设置为合适的类型。

  4. 使用 return 语句返回运算的结果。

我们先来看一下 operator<< 在用于输出时是如何工作的。考虑一个简单的表达式,如 std::cout << 5std::cout 的类型为 std::ostream(这是标准库中的用户定义类型),5int 类型的文本。当计算此表达式时,编译器会查找一个能够处理 std::ostreamint 类型参数的重载 operator<< 函数。它会找到这样的一个函数(也是标准 I/O 库的一部分),并调用它。在该函数内部,使用 std::coutx 输出到控制台(具体实现方式取决于具体实现)。最后,operator<< 函数返回其左操作数(在本例中为 std::cout),从而实现后续对 operator<< 的调用能够链式执行。考虑到上述情况,让我们实现 operator<< 的重载来打印 Color

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <iostream>
#include <string_view>

enum Color
{
black,
red,
blue,
};

constexpr std::string_view getColorName(Color color)
{
switch (color)
{
case black: return "black";
case red: return "red";
case blue: return "blue";
default: return "???";
}
}

// Teach operator<< how to print a Color
// std::ostream is the type of std::cout, std::cerr, etc...
// The return type and parameter type are references (to prevent copies from being made)
std::ostream& operator<<(std::ostream& out, Color color)
{
out << getColorName(color); // print our color's name to whatever output stream out
return out; // operator<< conventionally returns its left operand

// The above can be condensed to the following single line:
// return out << getColorName(color)
}

int main()
{
Color shirt{ blue };
std::cout << "Your shirt is " << shirt << '\n'; // it works!

return 0;
}

输出:

1
Your shirt is blue

重载operator << 也一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
#include <iostream>
#include <limits>
#include <optional>
#include <string>
#include <string_view>

enum Pet
{
cat, // 0
dog, // 1
pig, // 2
whale, // 3
};

constexpr std::string_view getPetName(Pet pet)
{
switch (pet)
{
case cat: return "cat";
case dog: return "dog";
case pig: return "pig";
case whale: return "whale";
default: return "???";
}
}

constexpr std::optional<Pet> getPetFromString(std::string_view sv)
{
if (sv == "cat") return cat;
if (sv == "dog") return dog;
if (sv == "pig") return pig;
if (sv == "whale") return whale;

return {};
}

// pet is an in/out parameter
std::istream& operator>>(std::istream& in, Pet& pet)
{
std::string s{};
in >> s; // get input string from user

std::optional<Pet> match { getPetFromString(s) };
if (match) // if we found a match
{
pet = *match; // dereference std::optional to get matching enumerator
return in;
}

// We didn't find a match, so input must have been invalid
// so we will set input stream to fail state
in.setstate(std::ios_base::failbit);

// On an extraction failure, operator>> zero-initializes fundamental types
// Uncomment the following line to make this operator do the same thing
// pet = {};

return in;
}

int main()
{
std::cout << "Enter a pet: cat, dog, pig, or whale: ";
Pet pet{};
std::cin >> pet;

if (std::cin) // if we found a match
std::cout << "You chose: " << getPetName(pet) << '\n';
else
{
std::cin.clear(); // reset the input stream to good
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
std::cout << "Your pet was not valid\n";
}

return 0;
}

6. 限定枚举

限定枚举(scoped enumerations)的工作方式与非限定枚举类似,但有两个主要区别:

  1. 它们不会隐式转换为整数。

  2. 枚举成员(enumerators)仅会被放入枚举类型的作用域中(而不会进入定义该枚举的外部作用域)。

要创建一个限定枚举(scoped enumeration),我们使用关键字 enum class。限定枚举的定义方式与非限定枚举(unscoped enumeration)的定义基本相同。以下是一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
int main()
{
enum class Color // "enum class" defines this as a scoped enumeration rather than an unscoped enumeration
{
red, // red is considered part of Color's scope region
blue,
};

enum class Fruit
{
banana, // banana is considered part of Fruit's scope region
apple,
};

Color color { Color::red }; // note: red is not directly accessible, we have to use Color::red
Fruit fruit { Fruit::banana }; // note: banana is not directly accessible, we have to use Fruit::banana

if (color == fruit) // compile error: the compiler doesn't know how to compare different types Color and Fruit
std::cout << "color and fruit are equal\n";
else
std::cout << "color and fruit are not equal\n";

return 0;
}

此程序在第 19 行生成编译错误,因为限定枚举不会转换为任何可以与另一种类型进行比较的类型。

class 关键字(以及 static 关键字)是 C++ 语言中重载最多的关键字之一,根据上下文的不同,它可能具有不同的含义。尽管限定枚举使用 class 关键字,但它们不被视为“类类型”(保留给结构体、类和联合体)。

与无范围枚举不同,无范围枚举将其枚举成员置于与枚举本身相同的范围内,而有范围的枚举将其枚举成员放置在枚举的范围区域中。换句话说,限定枚举的作用类似于其枚举成员的命名空间。这种内置的命名空间有助于减少全局命名空间污染,以及在全局范围内使用范围枚举时发生名称冲突的可能性。要访问限定枚举成员,我们这样做就像它在与限定枚举同名的命名空间中一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>

int main()
{
enum class Color // "enum class" defines this as a scoped enum rather than an unscoped enum
{
red, // red is considered part of Color's scope region
blue,
};

std::cout << red << '\n'; // compile error: red not defined in this scope region
std::cout << Color::red << '\n'; // compile error: std::cout doesn't know how to print this (will not implicitly convert to int)

Color color { Color::blue }; // okay

return 0;
}

由于限定枚举为枚举成员提供自己的隐式命名空间,因此无需将限定枚举放在另一个限定区域(如命名空间)内,这将是多余的。

与非限定枚举成员不同,限定枚举成员不会隐式转换为整数。在大多数情况下,这是一个好处,因为枚举成员很少需要转换为整数,这有助于防止语义错误,例如将来自不同枚举的成员进行比较,或出现像 red + 5 这样的表达式。

你仍然可以比较同一范围枚举中的枚举成员(因为它们属于同一类型):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
int main()
{
enum class Color
{
red,
blue,
};

Color shirt { Color::red };

if (shirt == Color::red) // this Color to Color comparison is okay
std::cout << "The shirt is red!\n";
else if (shirt == Color::blue)
std::cout << "The shirt is blue!\n";

return 0;
}

有时,能够将限定枚举成员视为整数值是有用的。在这些情况下,您可以使用 static_cast 显式将范围枚举成员转换为整数。在 C++23 中更好的选择是使用 std::to_underlying() (在头文件中定义),它将枚举成员转换为枚举的基础类型的值。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <utility> // for std::to_underlying() (C++23)

int main()
{
enum class Color
{
red,
blue,
};

Color color { Color::blue };

std::cout << color << '\n'; // won't work, because there's no implicit conversion to int
std::cout << static_cast<int>(color) << '\n'; // explicit conversion to int, will print 1
std::cout << std::to_underlying(color) << '\n'; // convert to underlying type, will print 1 (C++23)

return 0;
}

相反,你也可以将整数static_cast到一个限定枚举成员,这在进行用户输入时可能很有用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>

int main()
{
enum class Pet
{
cat, // assigned 0
dog, // assigned 1
pig, // assigned 2
whale, // assigned 3
};

std::cout << "Enter a pet (0=cat, 1=dog, 2=pig, 3=whale): ";

int input{};
std::cin >> input; // input an integer

Pet pet{ static_cast<Pet>(input) }; // static_cast our integer to a Pet

return 0;
}

从 C++17 开始,你可以使用不带 static_cast 的整数值对限定枚举进行列表初始化(与非限定枚举不同,你不需要指定基类):

1
2
// using enum class Pet from prior example
Pet pet { 1 }; // okay

限定枚举很棒,但缺少对整数的隐式转换有时可能是一个痛点。如果我们需要经常将限定枚举转换为整数(例如,我们想使用限定枚举成员作为数组索引的情况),那么每次想要转换时都必须使用 static_cast 可能会使我们的代码非常混乱。如果你发现自己处于更容易地将限定枚举成员转换为整数会很有用的情况,一个有用的技巧是重载一元operator + 来执行此转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>
#include <type_traits> // for std::underlying_type_t

enum class Animals
{
chicken, // 0
dog, // 1
cat, // 2
elephant, // 3
duck, // 4
snake, // 5

maxAnimals,
};

// Overload the unary + operator to convert an enum to the underlying type
// adapted from https://stackoverflow.com/a/42198760, thanks to Pixelchemist for the idea
// In C++23, you can #include <utility> and return std::to_underlying(a) instead
template <typename T>
constexpr auto operator+(T a) noexcept
{
return static_cast<std::underlying_type_t<T>>(a);
}

int main()
{
std::cout << +Animals::elephant << '\n'; // convert Animals::elephant to an integer using unary operator+

return 0;
}

输出:

1
3

在 C++20 中引入的 using enum 语句将所有枚举成员从枚举导入到当前作用域中。当与枚举类类型一起使用时,这允许我们访问枚举类枚举成员,而无需在每个枚举类的前缀前加上枚举类的名称。这在我们有许多相同、重复的前缀的情况下非常有用,例如在 switch 语句中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>
#include <string_view>

enum class Color
{
black,
red,
blue,
};

constexpr std::string_view getColor(Color color)
{
using enum Color; // bring all Color enumerators into current scope (C++20)
// We can now access the enumerators of Color without using a Color:: prefix

switch (color)
{
case black: return "black"; // note: black instead of Color::black
case red: return "red";
case blue: return "blue";
default: return "???";
}
}

int main()
{
Color shirt{ Color::blue };

std::cout << "Your shirt is " << getColor(shirt) << '\n';

return 0;
}

在上面的例子中,Color 是一个枚举类,因此我们通常会使用完全限定的名称(例如 Color::blue)来访问枚举成员。但是,在函数 getColor() 中,我们添加了using enum Color; 的语句,它允许我们在没有 Color:: 前缀的情况下访问这些枚举成员。这样我们就不必在 switch 语句中使用多个冗余的明显前缀。

7. 结构体、成员和成员选择简介

结构体是一种程序定义的数据类型,它允许我们将多个变量捆绑到一个类型中。这使得相关变量集中管理变得更加简单!因为结构体是程序定义的类型,所以我们首先必须告诉编译器我们的结构体类型是什么样子的,然后才能开始使用它。下面是一个简化员工的结构体定义示例:

1
2
3
4
5
6
struct Employee
{
int id {};
int age {};
double wage {};
};

struct 关键字用于告诉编译器我们正在定义一个结构体,这里我们将其命名为 Employee(因为程序自定义类型通常使用首字母大写的命名方式)。然后,在一对花括号内,我们定义了每个 Employee 对象将包含的变量。在这个例子中,每个创建的 Employee 将有三个变量:一个 int 类型的 id,一个 int 类型的 age,以及一个 double 类型的 wage。构成结构体的变量被称为数据成员(或成员变量)。就像我们使用一组空的大括号对普通变量进行值初始化一样,每个成员变量后面的空大括号可确保在创建 Employee 时对 Employee 内部的成员变量进行值初始化。Employee 只是一个类型定义 —— 此时实际上没有创建任何对象。

在 C++ 中,成员是属于结构体(或类)的变量、函数或类型。所有成员都必须在结构体(或类)定义中声明。

为了使用 Employee 类型,我们只需定义一个 Employee 类型的变量:

1
Employee joe {}; // Employee is the type, joe is the variable name

就像任何其他类型的一样,可以定义同一结构体类型的多个变量:

1
2
Employee joe {}; // create an Employee struct for Joe
Employee frank {}; // create an Employee struct for Frank

要访问特定成员变量,我们在 struct 变量名称和成员名称之间使用成员选择运算符operator.)。例如,要访问 Joe 的 age 成员,我们将使用 joe.age。结构成员变量的工作方式与普通变量类似,因此可以对它们进行常规作,包括赋值、算术、比较等。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

struct Employee
{
int id {};
int age {};
double wage {};
};

int main()
{
Employee joe {};

joe.age = 32; // use member selection operator (.) to select the age member of variable joe

std::cout << joe.age << '\n'; // print joe's age

return 0;
}

输出:

1
32

结构体的最大优点之一是我们只需要为每个结构体变量创建一个新名称(成员名称作为结构体类型定义的一部分是固定的)。在下面的示例中,我们实例化两个 Employee 对象:joefrank

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <iostream>

struct Employee
{
int id {};
int age {};
double wage {};
};

int main()
{
Employee joe {};
joe.id = 14;
joe.age = 32;
joe.wage = 60000.0;

Employee frank {};
frank.id = 15;
frank.age = 28;
frank.wage = 45000.0;

int totalAge { joe.age + frank.age };
std::cout << "Joe and Frank have lived " << totalAge << " total years\n";

if (joe.wage > frank.wage)
std::cout << "Joe makes more than Frank\n";
else if (joe.wage < frank.wage)
std::cout << "Joe makes less than Frank\n";
else
std::cout << "Joe and Frank make the same amount\n";

// Frank got a promotion
frank.wage += 5000.0;

// Today is Joe's birthday
++joe.age; // use pre-increment to increment Joe's age by 1

return 0;
}

8. 结构体聚合初始化

与普通变量非常相似,默认情况下不会初始化数据成员。请考虑以下结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>

struct Employee
{
int id; // note: no initializer here
int age;
double wage;
};

int main()
{
Employee joe; // note: no initializer here either
std::cout << joe.id << '\n';

return 0;
}

因为我们没有提供任何初始化器,所以当 joe 实例化时,joe.idjoe.agejoe.wage 都将被取消初始化。然后,当我们尝试打印 joe.id 的值时,我们将得到未定义行为。

在通用编程中,聚合数据类型(aggregate data type,简称聚合)是指任何可以包含多个数据成员的类型。有些聚合类型允许成员具有不同的类型(例如结构体struct),而其他类型则要求所有成员必须是相同的类型(例如数组array)。在 C++ 中,聚合的定义更为狭窄,并且相对复杂得多。

简单来说,在 C++ 中,聚合(aggregate)要么是一个 C 风格的数组,要么是一个类类型(struct、class 或 union),并满足以下条件:

  • 没有用户声明的构造函数

  • 没有私有(private)或受保护(protected)的非静态数据成员

仅包含数据成员的结构体(struct)属于聚合类型(aggregate)。

聚合类型(aggregates)使用一种称为**聚合初始化(aggregate initialization)**的初始化方式,允许我们直接初始化聚合类型的成员。为此,我们提供一个初始化列表(initializer list)作为初始化器,这个列表是一个用大括号括起来的、逗号分隔的值列表。聚合初始化有两种主要形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Employee
{
int id {};
int age {};
double wage {};
};

int main()
{
Employee frank = { 1, 32, 60000.0 }; // copy-list initialization using braced list
Employee joe { 2, 28, 45000.0 }; // list initialization using braced list (preferred)

return 0;
}

如果一个聚合类型在初始化时,初始化值的数量少于成员的数量,那么每个没有显式初始化的成员将按以下方式进行初始化:

  • 如果成员具有默认成员初始化器,则使用该初始化器。

  • 否则,成员将从一个空的初始化列表中进行拷贝初始化。在大多数情况下,这将对这些成员执行值初始化(对于类类型,即使存在列表构造函数,也会调用默认构造函数)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Employee
{
int id {};
int age {};
double wage { 76000.0 };
double whatever;
};

int main()
{
Employee joe { 2, 28 }; // joe.whatever will be value-initialized to 0.0

return 0;
}

在上面的例子中,joe.id 将被初始化为 2joe.age 将被初始化为 28。因为 joe.wage 没有显式初始化器,但有一个默认成员初始化器,所以 joe.wage 将被初始化为 76000.0。最后,因为 joe.whatever 没有显式初始化器,joe.whatever 将被值初始化为 0.0

这意味着我们通常可以使用空的初始化列表对 struct 的所有成员进行值初始化:

1
Employee joe {}; // value-initialize all members

我们可以通过重载 operator<< 以打印结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>

struct Employee
{
int id {};
int age {};
double wage {};
};

std::ostream& operator<<(std::ostream& out, const Employee& e)
{
out << e.id << ' ' << e.age << ' ' << e.wage;
return out;
}

int main()
{
Employee joe { 2, 28 }; // joe.wage will be value-initialized to 0.0
std::cout << joe << '\n';

return 0;
}

输出:

1
2 28 0

上面重载operator << 输出的三个值并不直观,因为没有指示这些值的含义。让我们做同样的例子,但更新我们的 output 函数,使其更具描述性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>

struct Employee
{
int id {};
int age {};
double wage {};
};

std::ostream& operator<<(std::ostream& out, const Employee& e)
{
out << "id: " << e.id << " age: " << e.age << " wage: " << e.wage;
return out;
}

int main()
{
Employee joe { 2, 28 }; // joe.wage will be value-initialized to 0.0
std::cout << joe << '\n';

return 0;
}

输出:

1
id: 2 age: 28 wage: 0

结构体类型的变量可以是 const(或 constexpr),就像所有 const 变量一样,它们必须被初始化。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Rectangle
{
double length {};
double width {};
};

int main()
{
const Rectangle unit { 1.0, 1.0 };
const Rectangle zero { }; // value-initialize all members

return 0;
}

为帮助避免这种情况,C++20 添加了一种新的初始化结构体成员的方法,称为 指定初始化器(designated initializers)。指定初始化器允许你显式地定义哪个初始化值对应于哪个成员。成员可以使用 列表初始化(list initialization)复制初始化(copy initialization) 进行初始化,且必须按照成员在结构体中声明的顺序进行初始化,否则会产生警告或错误。未指定初始化器的成员将执行 值初始化(value initialization)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Foo
{
int a{ };
int b{ };
int c{ };
};

int main()
{
Foo f1{ .a{ 1 }, .c{ 3 } }; // ok: f1.a = 1, f1.b = 0 (value initialized), f1.c = 3
Foo f2{ .a = 1, .c = 3 }; // ok: f2.a = 1, f2.b = 0 (value initialized), f2.c = 3
Foo f3{ .b{ 2 }, .a{ 1 } }; // error: initialization order does not match order of declaration in struct

return 0;
}

我们可以单独为结构体的成员赋值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Employee
{
int id {};
int age {};
double wage {};
};

int main()
{
Employee joe { 1, 32, 60000.0 };

joe.age = 33; // Joe had a birthday
joe.wage = 66000.0; // and got a raise

return 0;
}

对于单个成员来说,这种方式是可以的,但当我们需要更新多个成员时就不太方便了。与使用初始化列表初始化结构体类似,你还可以使用初始化列表为结构体赋值(这会进行逐成员赋值):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Employee
{
int id {};
int age {};
double wage {};
};

int main()
{
Employee joe { 1, 32, 60000.0 };
joe = { joe.id, 33, 66000.0 }; // Joe had a birthday and got a raise

return 0;
}

请注意,由于我们不想更改 joe.id,我们需要在列表中提供 joe.id 的当前值作为占位符,以便逐成员赋值能够将 joe.id 赋给 joe.id。这种做法有点不太优雅。

指定初始化器也可以在列表赋值中使用(C++20):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Employee
{
int id {};
int age {};
double wage {};
};

int main()
{
Employee joe { 1, 32, 60000.0 };
joe = { .id = joe.id, .age = 33, .wage = 66000.0 }; // Joe had a birthday and got a raise

return 0;
}

一个结构体也可以使用另一个相同类型的结构体进行初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>

struct Foo
{
int a{};
int b{};
int c{};
};

std::ostream& operator<<(std::ostream& out, const Foo& f)
{
out << f.a << ' ' << f.b << ' ' << f.c;
return out;
}

int main()
{
Foo foo { 1, 2, 3 };

Foo x = foo; // copy-initialization
Foo y(foo); // direct-initialization
Foo z {foo}; // direct-list-initialization

std::cout << x << '\n';
std::cout << y << '\n';
std::cout << z << '\n';

return 0;
}

这将打印:

1
2
3
1 2 3
1 2 3
1 2 3

9. 默认成员初始化

当我们定义一个结构体(或类)类型时,可以为每个成员提供一个默认初始化值,作为类型定义的一部分。对于未标记为 static 的成员,这个过程有时被称为非静态成员初始化(non-static member initialization)。初始化值称为默认成员初始化器(default member initializer)。下面是一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Something
{
int x; // no initialization value (bad)
int y {}; // value-initialized by default
int z { 2 }; // explicit default value
};

int main()
{
Something s1; // s1.x is uninitialized, s1.y is 0, and s1.z is 2

return 0;
}

在上面的 Something 定义中,x 没有默认值,y 默认是值初始化的,z 的默认值是 2。如果用户在实例化 Something 类型的对象时未提供显式初始化值,则将使用这些默认成员初始化值。我们的 s1 对象没有初始化器,因此 s1 的成员被初始化为默认值。s1.x 没有默认初始化器,因此它保持未初始化状态。s1.y 是默认初始化的值,因此它的值为 0s1.z 初始化为值 2

列表初始化器中的显式值始终优先于默认成员初始化值:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Something
{
int x; // no default initialization value (bad)
int y {}; // value-initialized by default
int z { 2 }; // explicit default value
};

int main()
{
Something s2 { 5, 6, 7 }; // use explicit initializers for s2.x, s2.y, and s2.z (no default values are used)

return 0;
}

如果一个聚合(aggregate)是用初始化列表定义的:

  • 如果存在显式初始化值,则使用该显式值。

  • 如果缺少初始化器且存在默认成员初始化器,则使用默认值。

  • 如果缺少初始化器且没有默认成员初始化器,则进行值初始化(value initialization)。

如果一个聚合是没有初始化列表定义的:

  • 如果存在默认成员初始化器,则使用默认值。

  • 如果没有默认成员初始化器,该成员将保持未初始化状态。

成员始终按照声明的顺序进行初始化。以下示例概括了所有可能性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Something
{
int x; // no default initialization value (bad)
int y {}; // value-initialized by default
int z { 2 }; // explicit default value
};

int main()
{
Something s1; // No initializer list: s1.x is uninitialized, s1.y and s1.z use defaults
Something s2 { 5, 6, 7 }; // Explicit initializers: s2.x, s2.y, and s2.z use explicit values (no default values are used)
Something s3 {}; // Missing initializers: s3.x is value initialized, s3.y and s3.z use defaults

return 0;
}

最好为所有成员提供默认值。这可以确保即使变量定义中不包含初始化列表,成员也会被初始化。

10. 传递和返回结构体

与单个变量相比,使用结构体的一大优势是我们可以将整个结构体传递给需要处理成员的函数。结构体通常通过引用(通常通过 const 引用)传递,以避免产生副本。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>

struct Employee
{
int id {};
int age {};
double wage {};
};

void printEmployee(const Employee& employee) // note pass by reference here
{
std::cout << "ID: " << employee.id << '\n';
std::cout << "Age: " << employee.age << '\n';
std::cout << "Wage: " << employee.wage << '\n';
}

int main()
{
Employee joe { 14, 32, 24.15 };
Employee frank { 15, 28, 18.27 };

// Print Joe's information
printEmployee(joe);

std::cout << '\n';

// Print Frank's information
printEmployee(frank);

return 0;
}

在上面的例子中,我们将整个 Employee 传递给 printEmployee()

上述程序输出:

1
2
3
4
5
6
7
ID:   14
Age: 32
Wage: 24.15

ID: 15
Age: 28
Wage: 18.27

在前面的示例中,我们在将 Employee 变量 joe 传递给 printEmployee() 函数之前创建了该变量。在我们只使用一次变量的情况下,必须为变量命名并分隔该变量的创建和使用可能会增加复杂性。在这种情况下,最好改用临时对象。临时对象不是变量,因此它没有标识符。下面是与上述相同的示例,但我们已将变量 joefrank 替换为临时对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>

struct Employee
{
int id {};
int age {};
double wage {};
};

void printEmployee(const Employee& employee) // note pass by reference here
{
std::cout << "ID: " << employee.id << '\n';
std::cout << "Age: " << employee.age << '\n';
std::cout << "Wage: " << employee.wage << '\n';
}

int main()
{
// Print Joe's information
printEmployee(Employee { 14, 32, 24.15 }); // construct a temporary Employee to pass to function (type explicitly specified) (preferred)

std::cout << '\n';

// Print Frank's information
printEmployee({ 15, 28, 18.27 }); // construct a temporary Employee to pass to function (type deduced from parameter)

return 0;
}

我们可以通过两种方式创建临时 Employee。在第一次调用中,我们使用语法 Employee { 14, 32, 24.15 }。这会告知编译器创建一个 Employee 对象,并使用提供的初始值设定项对其进行初始化。这是首选语法,因为它清楚地表明我们正在创建什么样的临时对象,并且编译器无法误解我们的意图。在第二次调用中,我们使用语法 { 15, 28, 18.27 }。编译器足够聪明,可以理解必须将提供的参数转换为 Employee,以便函数调用成功。请注意,这种形式被视为隐式转换,因此在仅接受显式转换的情况下将无法使用。

考虑这样一种情况,我们有一个函数需要返回 3 维笛卡尔空间中的一个点。这样的点有 3 个属性:x 坐标、y 坐标和 z 坐标。但是函数只能返回一个值。那么我们如何将所有 3 个坐标返回给用户呢?一种常见的方法是返回一个结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>

struct Point3d
{
double x { 0.0 };
double y { 0.0 };
double z { 0.0 };
};

Point3d getZeroPoint()
{
// We can create a variable and return the variable (we'll improve this below)
Point3d temp { 0.0, 0.0, 0.0 };
return temp;
}

int main()
{
Point3d zero{ getZeroPoint() };

if (zero.x == 0.0 && zero.y == 0.0 && zero.z == 0.0)
std::cout << "The point is zero\n";
else
std::cout << "The point is not zero\n";

return 0;
}

输出:

1
The point is zero

在函数中定义的结构体通常按值返回,以免返回悬空引用。

同样,我们也可以返回一个临时对象:

1
2
3
4
Point3d getZeroPoint()
{
return Point3d { 0.0, 0.0, 0.0 }; // return an unnamed Point3d
}

在函数具有显式返回类型(例如 Point3d)的情况下,我们甚至可以在 return 语句中省略该类型:

1
2
3
4
5
6
Point3d getZeroPoint()
{
// We already specified the type at the function declaration
// so we don't need to do so here again
return { 0.0, 0.0, 0.0 }; // return an unnamed Point3d
}

这被视为隐式转换。

11. 结构体杂项

在 C++ 中,结构体(以及类)可以包含其他程序自定义类型的成员。对此,有两种实现方式。首先,我们可以在全局作用域中定义一个程序自定义类型,然后将其用作另一个程序自定义类型的成员:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>

struct Employee
{
int id {};
int age {};
double wage {};
};

struct Company
{
int numberOfEmployees {};
Employee CEO {}; // Employee is a struct within the Company struct
};

int main()
{
Company myCompany{ 7, { 1, 32, 55000.0 } }; // Nested initialization list to initialize Employee
std::cout << myCompany.CEO.wage << '\n'; // print the CEO's wage

return 0;
}

在上面的例子中,我们定义了一个 Employee 体,然后将其用作 Company 结构体中的成员。当我们初始化 Company 时,我们还可以使用嵌套初始化列表来初始化我们的 Employee。如果我们想知道 CEO 的薪水是多少,我们只需使用两次成员选择运算符:myCompany.CEO.wage

其次,类型也可以嵌套在其他类型中,因此如果 Employee 类型仅作为 Company 的一部分存在,那么 Employee 类型可以嵌套在 Company 结构体中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>

struct Company
{
struct Employee // accessed via Company::Employee
{
int id{};
int age{};
double wage{};
};

int numberOfEmployees{};
Employee CEO{}; // Employee is a struct within the Company struct
};

int main()
{
Company myCompany{ 7, { 1, 32, 55000.0 } }; // Nested initialization list to initialize Employee
std::cout << myCompany.CEO.wage << '\n'; // print the CEO's wage

return 0;
}

前面我们介绍了所有者和查看者的双重概念。所有者管理自己的数据,并控制何时销毁数据。查看者查看其他人的数据,并且无法控制数据何时被更改或销毁。在大多数情况下,我们希望结构体(和类)成为它们所包含的数据的所有者。这提供了一些有用的好处:

  • 只要结构体(或类)有效,数据成员就有效。

  • 这些数据成员的值不会意外更改。

使结构体(或类)成为所有者的最简单方法是为每个数据成员提供所有者类型(例如,不是查看器、指针或引用)。如果结构体或类的数据成员都是所有者,则结构体或类本身会自动成为所有者。

通常,结构体的大小是其所有成员的大小之和,但并非总是如此!请考虑以下程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

struct Foo
{
short a {};
int b {};
double c {};
};

int main()
{
std::cout << "The size of short is " << sizeof(short) << " bytes\n";
std::cout << "The size of int is " << sizeof(int) << " bytes\n";
std::cout << "The size of double is " << sizeof(double) << " bytes\n";

std::cout << "The size of Foo is " << sizeof(Foo) << " bytes\n";

return 0;
}

示例输出:

1
2
3
4
The size of short is 2 bytes
The size of int is 4 bytes
The size of double is 8 bytes
The size of Foo is 16 bytes

注意 short + int + double 的大小是 14 字节,但 Foo 的大小是 16 字节!事实证明,我们只能说结构体的大小至少与它包含的所有变量的大小一样大。但它可能会更大!出于性能原因,编译器有时会在结构体中添加间隙(这称为填充)。

12. 通过指针和引用进行成员选择

使用普通变量或引用,我们可以直接访问对象。但是,由于指针保存地址,因此我们首先需要取消引用指针以获取对象,然后才能对其进行任何作。因此,从指向结构体的指针访问成员的一种方法如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>

struct Employee
{
int id{};
int age{};
double wage{};
};

int main()
{
Employee joe{ 1, 34, 65000.0 };

++joe.age;
joe.wage = 68000.0;

Employee* ptr{ &joe };
std::cout << (*ptr).id << '\n'; // Not great but works: First dereference ptr, then use member selection

return 0;
}

但是,这有点难看,特别是因为我们需要将取消引用作括起来,以便它优先于成员选择操作。为了使语法更简洁,C++ 提供了从指针运算符 (->)(有时也称为箭头运算符)中选择成员,该运算符可用于从指向对象的指针中选择成员:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>

struct Employee
{
int id{};
int age{};
double wage{};
};

int main()
{
Employee joe{ 1, 34, 65000.0 };

++joe.age;
joe.wage = 68000.0;

Employee* ptr{ &joe };
std::cout << ptr->id << '\n'; // Better: use -> to select member from pointer to object

return 0;
}

从指针运算符 (->) 中选择成员的工作方式与成员选择运算符 (.) 的工作方式相同,但在选择成员之前对指针对象进行隐式取消引用。因此 ptr->id 等同于 (*ptr).id

如果通过 operator-> 访问的成员是指向类类型的指针,则可以在同一表达式中再次应用 operator-> 来访问该类类型的成员。以下示例说明了这一点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>

struct Point
{
double x {};
double y {};
};

struct Triangle
{
Point* a {};
Point* b {};
Point* c {};
};

int main()
{
Point a {1,2};
Point b {3,7};
Point c {10,2};

Triangle tr { &a, &b, &c };
Triangle* ptr {&tr};

// ptr is a pointer to a Triangle, which contains members that are pointers to a Point
// To access member y of Point c of the Triangle pointed to by ptr, the following are equivalent:

// access via operator.
std::cout << (*(*ptr).c).y << '\n'; // ugly!

// access via operator->
std::cout << ptr -> c -> y << '\n'; // much nicer
}

当按顺序使用多个operator > 时(例如 ptr->c->y),表达式可能难以读取。在成员和 operator-> 之间添加空格(例如 ptr -> c -> y)可以更轻松地区分正在访问的成员和运算符。

成员选择运算符始终应用于当前选定的变量。如果混合了指针和普通成员变量,则可以看到成员选择,其中 . -> 都按顺序使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>
#include <string>

struct Paw
{
int claws{};
};

struct Animal
{
std::string name{};
Paw paw{};
};

int main()
{
Animal puma{ "Puma", { 5 } };

Animal* ptr{ &puma };

// ptr is a pointer, use ->
// paw is not a pointer, use .

std::cout << (ptr->paw).claws << '\n';

return 0;
}

请注意,在 (ptr->paw).claws 的情况下,括号不是必需的,因为 operator->operator.evaluate 都按从左到右的顺序进行,但它确实略微提高了可读性。

13. 类模板

与函数模板是用于实例化函数的模板定义非常相似,类模板是用于实例化类类型的模板定义。

提醒一下,这是我们的 int 对结构体定义:

1
2
3
4
5
struct Pair
{
int first{};
int second{};
};

让我们将 pair 类重写为类模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>

template <typename T>
struct Pair
{
T first{};
T second{};
};

int main()
{
Pair<int> p1{ 5, 6 }; // instantiates Pair<int> and creates object p1
std::cout << p1.first << ' ' << p1.second << '\n';

Pair<double> p2{ 1.2, 3.4 }; // instantiates Pair<double> and creates object p2
std::cout << p2.first << ' ' << p2.second << '\n';

Pair<double> p3{ 7.8, 9.0 }; // creates object p3 using prior definition for Pair<double>
std::cout << p3.first << ' ' << p3.second << '\n';

return 0;
}

由于编译器将 Pair<int>Pair<double> 视为单独的类型,因此我们可以使用按参数类型区分的重载函数。这是一个完整的示例,其中 max() 被实现为函数模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>

template <typename T>
struct Pair
{
T first{};
T second{};
};

template <typename T>
constexpr T max(Pair<T> p)
{
return (p.first < p.second ? p.second : p.first);
}

int main()
{
Pair<int> p1{ 5, 6 };
std::cout << max<int>(p1) << " is larger\n"; // explicit call to max<int>

Pair<double> p2{ 1.2, 3.4 };
std::cout << max(p2) << " is larger\n"; // call to max<double> using template argument deduction (prefer this)

return 0;
}

类模板可以包含一些使用模板类型的成员,而其他成员使用普通(非模板)类型。例如:

1
2
3
4
5
6
template <typename T>
struct Foo
{
T first{}; // first will have whatever type T is replaced with
int second{}; // second will always have type int, regardless of what type T is
};

类模板也可以具有多个模板类型。例如,如果我们希望 Pair 类的两个成员能够具有不同的类型,我们可以用两种模板类型定义 Pair 类模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>

template <typename T, typename U>
struct Pair
{
T first{};
U second{};
};

template <typename T, typename U>
void print(Pair<T, U> p)
{
std::cout << '[' << p.first << ", " << p.second << ']';
}

int main()
{
Pair<int, double> p1{ 1, 2.3 }; // a pair holding an int and a double
Pair<double, int> p2{ 4.5, 6 }; // a pair holding a double and an int
Pair<int, int> p3{ 7, 8 }; // a pair holding two ints

print(p2);

return 0;
}

考虑上面示例中的 print() 函数模板:

1
2
3
4
5
template <typename T, typename U>
void print(Pair<T, U> p)
{
std::cout << '[' << p.first << ", " << p.second << ']';
}

由于我们已将函数参数显式定义为 Pair<T, U>,因此只有 Pair<T, U> 类型的参数(或可转换为 Pair<T, U> 的参数)才会匹配。如果我们只想能够使用 Pair<T, U> 参数调用我们的函数,这是理想的选择。为此,我们只需使用类型模板参数作为函数参数即可。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>

template <typename T, typename U>
struct Pair
{
T first{};
U second{};
};

struct Point
{
int first{};
int second{};
};

template <typename T>
void print(T p) // type template parameter will match anything
{
std::cout << '[' << p.first << ", " << p.second << ']'; // will only compile if type has first and second members
}

int main()
{
Pair<double, int> p1{ 4.5, 6 };
print(p1); // matches print(Pair<double, int>)

std::cout << '\n';

Point p2 { 7, 8 };
print(p2); // matches print(Point)

std::cout << '\n';

return 0;
}

有一种情况可能会产生误导。请考虑以下版本的 print()

1
2
3
4
5
6
7
8
9
10
11
12
template <typename T, typename U>
struct Pair // defines a class type named Pair
{
T first{};
U second{};
};

template <typename Pair> // defines a type template parameter named Pair (shadows Pair class type)
void print(Pair p) // this refers to template parameter Pair, not class type Pair
{
std::cout << '[' << p.first << ", " << p.second << ']';
}

你可能希望此函数仅在使用 Pair 类类型参数调用时匹配。但是这个版本的 print() 在功能上与模板参数被命名为 T 的先前版本相同,并且将与任何类型匹配。这里的问题是,当我们将 Pair 定义为类型模板参数时,它会掩盖名称 Pair 在全局范围内的其他用法。因此,在函数模板中, Pair 引用模板参数 Pair,而不是类类型 Pair。由于类型模板参数将匹配任何类型,因此此 Pair 匹配任何参数类型,而不仅仅是类类型 Pair 的参数类型!

这是坚持使用简单模板参数名称(如 TUN)的一个很好的理由,因为它们不太可能隐藏类类型名称。

由于使用数据对很常见,因此 C++ 标准库包含一个名为 std::pair 的类模板(在 <utility> 标头中),该模板的定义与上一节中具有多个模板类型的 Pair 类模板相同。事实上,我们可以换掉我们为 std::pair 开发的 pair 结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
#include <utility>

template <typename T, typename U>
void print(std::pair<T, U> p)
{
// the members of std::pair have predefined names `first` and `second`
std::cout << '[' << p.first << ", " << p.second << ']';
}

int main()
{
std::pair<int, double> p1{ 1, 2.3 }; // a pair holding an int and a double
std::pair<double, int> p2{ 4.5, 6 }; // a pair holding a double and an int
std::pair<int, int> p3{ 7, 8 }; // a pair holding two ints

print(p2);

return 0;
}

在本课中,我们开发了自己的 Pair 类来展示工作原理,但在实际代码中,您应该更喜欢使用 std::pair 而不是编写自己的类。

与函数模板一样,类模板通常在头文件中定义,因此它们可以包含在任何需要它们的代码文件中。模板定义和类型定义都不受单一定义规则的约束,因此这不会造成问题:

pair.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#ifndef PAIR_H
#define PAIR_H

template <typename T>
struct Pair
{
T first{};
T second{};
};

template <typename T>
constexpr T max(Pair<T> p)
{
return (p.first < p.second ? p.second : p.first);
}

#endif

foo.cpp

1
2
3
4
5
6
7
8
#include "pair.h"
#include <iostream>

void foo()
{
Pair<int> p1{ 1, 2 };
std::cout << max(p1) << " is larger\n";
}

main.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "pair.h"
#include <iostream>

void foo(); // forward declaration for function foo()

int main()
{
Pair<double> p2 { 3.4, 5.6 };
std::cout << max(p2) << " is larger\n";

foo();

return 0;
}

14. 类模板参数推导(CTAD)和推导指南

从 C++17 开始,当从类模板实例化对象时,编译器可以从对象的初始化器的类型中推断出模板类型(这称为类模板参数推导或简称 CTAD)。例如:

1
2
3
4
5
6
7
8
9
#include <utility> // for std::pair

int main()
{
std::pair<int, int> p1{ 1, 2 }; // explicitly specify class template std::pair<int, int> (C++11 onward)
std::pair p2{ 1, 2 }; // CTAD used to deduce std::pair<int, int> from the initializers (C++17)

return 0;
}

仅当不存在模板参数列表时,才会执行 CTAD。因此,以下两个都是错误:

1
2
3
4
5
6
7
8
9
#include <utility> // for std::pair

int main()
{
std::pair<> p1 { 1, 2 }; // error: too few template arguments, both arguments not deduced
std::pair<int> p2 { 3, 4 }; // error: too few template arguments, second argument not deduced

return 0;
}

由于 CTAD 是一种类型推导形式,我们可以使用文本后缀来更改推导的类型:

1
2
3
4
5
6
7
8
9
#include <utility> // for std::pair

int main()
{
std::pair p1 { 3.4f, 5.6f }; // deduced to pair<float, float>
std::pair p2 { 1u, 2u }; // deduced to pair<unsigned int, unsigned int>

return 0;
}

在大多数情况下,类模板参数推导(CTAD)可以直接使用。然而,在某些情况下,编译器可能需要一些额外的帮助才能正确推导模板参数。你可能会惊讶地发现,以下程序(几乎与上述使用 std::pair 的例子相同)在 C++17(仅此版本)中无法编译:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// define our own Pair type
template <typename T, typename U>
struct Pair
{
T first{};
U second{};
};

int main()
{
Pair<int, int> p1{ 1, 2 }; // ok: we're explicitly specifying the template arguments
Pair p2{ 1, 2 }; // compile error in C++17 (okay in C++20)

return 0;
}

如果你在 C17 中编译它,你可能会得到一些关于“类模板参数推导失败”或“无法推导模板参数”或“没有可行的构造函数或推导指南”的错误。这是因为在 C17 中,CTAD 不知道如何推断聚合类模板的模板参数。为了解决这个问题,我们可以为编译器提供一个 Infer guide,它告诉编译器如何推导给定类模板的模板参数。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template <typename T, typename U>
struct Pair
{
T first{};
U second{};
};

// Here's a deduction guide for our Pair (needed in C++17 only)
// Pair objects initialized with arguments of type T and U should deduce to Pair<T, U>
template <typename T, typename U>
Pair(T, U) -> Pair<T, U>;

int main()
{
Pair<int, int> p1{ 1, 2 }; // explicitly specify class template Pair<int, int> (C++11 onward)
Pair p2{ 1, 2 }; // CTAD used to deduce Pair<int, int> from the initializers (C++17)

return 0;
}

首先,我们使用与我们的 Pair 类相同的模板类型定义。这是合理的,因为如果我们的推导指南要告诉编译器如何推导 Pair<T, U> 的类型,我们必须定义 T 和 U 是什么(模板类型)。其次,在箭头的右边,我们放置我们希望编译器帮助推导的类型。在这种情况下,我们希望编译器能够推导 Pair<T, U> 类型的对象的模板参数,所以我们在这里正是放入了这个内容。最后,在箭头的左边,我们告诉编译器应该查找哪种声明。在这种情况下,我们告诉编译器查找一个有两个参数(一个类型为 T,另一个类型为 U)的 Pair 声明。我们也可以将其写为 Pair(T t, U u)(其中 t 和 u 是参数的名称,但由于我们不使用 t 和 u,所以不需要给它们命名)。综上所述,我们告诉编译器,如果它看到一个有两个参数(分别为 T 和 U 类型)的 Pair 声明,它应该推导该类型为 Pair<T, U>

就像函数参数可以具有默认参数一样,模板参数也可以被赋予默认值。当模板参数未明确指定且无法推导时,将使用这些参数。以下是对上述 Pair<T, U> 类模板程序的修改,类型模板参数 TU 默认为 int 类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template <typename T=int, typename U=int> // default T and U to type int
struct Pair
{
T first{};
U second{};
};

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

int main()
{
Pair<int, int> p1{ 1, 2 }; // explicitly specify class template Pair<int, int> (C++11 onward)
Pair p2{ 1, 2 }; // CTAD used to deduce Pair<int, int> from the initializers (C++17)

Pair p3; // uses default Pair<int, int>

return 0;
}

使用非静态成员初始化初始化类类型的成员时,CTAD 在此上下文中将不起作用。必须显式指定所有模板参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <utility> // for std::pair

struct Foo
{
std::pair<int, int> p1{ 1, 2 }; // ok, template arguments explicitly specified
std::pair p2{ 1, 2 }; // compile error, CTAD can't be used in this context
};

int main()
{
std::pair p3{ 1, 2 }; // ok, CTAD can be used here
return 0;
}

CTAD 代表类模板参数推导,而不是类模板参数推导,因此它只会推导模板参数的类型,而不是模板参数。因此,CTAD 不能在函数参数中使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
#include <utility>

void print(std::pair p) // compile error, CTAD can't be used here
{
std::cout << p.first << ' ' << p.second << '\n';
}

int main()
{
std::pair p { 1, 2 }; // p deduced to std::pair<int, int>
print(p);

return 0;
}

在这种情况下,你应该改用模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
#include <utility>

template <typename T, typename U>
void print(std::pair<T, U> p)
{
std::cout << p.first << ' ' << p.second << '\n';
}

int main()
{
std::pair p { 1, 2 }; // p deduced to std::pair<int, int>
print(p);

return 0;
}

15. 别名模板

为显式指定所有模板参数的类模板创建类型别名的工作方式与普通类型别名类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>

template <typename T>
struct Pair
{
T first{};
T second{};
};

template <typename T>
void print(const Pair<T>& p)
{
std::cout << p.first << ' ' << p.second << '\n';
}

int main()
{
using Point = Pair<int>; // create normal type alias
Point p { 1, 2 }; // compiler replaces this with Pair<int>

print(p);

return 0;
}

在其他情况下,我们可能希望为一个模板类定义类型别名,但并非所有模板参数都在别名中定义(而是由类型别名的用户提供)。为了实现这一点,我们可以定义一个别名模板,这是一个可以用来实例化类型别名的模板。就像类型别名不会定义独立的类型一样,别名模板也不会定义独立的类型。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>

template <typename T>
struct Pair
{
T first{};
T second{};
};

// Here's our alias template
// Alias templates must be defined in global scope
template <typename T>
using Coord = Pair<T>; // Coord is an alias for Pair<T>

// Our print function template needs to know that Coord's template parameter T is a type template parameter
template <typename T>
void print(const Coord<T>& c)
{
std::cout << c.first << ' ' << c.second << '\n';
}

int main()
{
Coord<int> p1 { 1, 2 }; // Pre C++-20: We must explicitly specify all type template argument
Coord p2 { 1, 2 }; // In C++20, we can use alias template deduction to deduce the template arguments in cases where CTAD works

std::cout << p1.first << ' ' << p1.second << '\n';
print(p2);

return 0;
}

要注意的事项:

  • 与普通类型别名(可以在块内定义)不同,别名模板必须在全局范围内定义(就像所有模板一样)。

  • 在 C++20 之前,当我们使用别名模板实例化对象时,我们必须显式指定模板参数。从 C++20 开始,我们可以使用别名模板推导,在别名类型与 CTAD 一起使用的情况下,它将从初始化器中推断出模板参数的类型。

  • 由于 CTAD 不适用于函数参数,因此当我们使用别名模板作为函数参数时,必须显式定义别名模板使用的模板参数。换句话说,我们这样做:

    1
    2
    3
    4
    5
    template <typename T>
    void print(const Coord<T>& c)
    {
    std::cout << c.first << ' ' << c.second << '\n';
    }

    而不是:

    1
    2
    3
    4
    void print(const Coord& c) // won't work, missing template arguments
    {
    std::cout << c.first << ' ' << c.second << '\n';
    }

参考资料

Learn C++ – Skill up with our free tutorials