第10章 表达式(Expressions)
目录
10.3.2 计算顺序(Order of Evaluation)
10.3.3 运算符优先级(Operator Precedence)
10.3.4 临时对象(Temporary Objects)
10.4.1 符号常量(Symbolic Constants)
10.4.4 引用参数(Reference Arguments)
10.4.5 地址常量表达式(Address Constant Expressions)
10.5 隐式类型转换(Implicit Type Conversion)
10.5.2.4 指向成员的指针转换(Pointer-to-Member Conversion)
10.5.2.5 bool值转换(Boolean Conversions)
10.5.3 常见算术转换(Usual Arithmetic Conversions)
10.1 引言
本章详细讨论了表达式。在 C++ 中,赋值是表达式,函数调用是表达式,对象的构造是表达式,还有许多其他超出传统算术表达式求值的操作。为了给人留下表达式如何使用的印象并在上下文中展示它们,我首先引入了一个完整的小程序——一个简单的“桌面计算器”。接下来,列出了完整的运算符集,并简要概述了它们对内置类型的含义。需要更广泛解释的运算符将在第 11 章中讨论。
10.2 一个桌面计算器程序
考虑一个简单的桌面计算器程序,它提供四种标准算术运算作为浮点数的中缀运算符。用户还可以定义变量。例如,给定输入
r = 2.5
area = pi ∗ r ∗ r
(pi 是预定义的)计算器程序将会写入
2.5
19.635
其中 2.5 是第一行输入的结果,19.635 是第二行输入的结果。
计算器由四个主要部分组成:解析器、输入函数、符号表和驱动程序。实际上,这构成一个微型编译器,其中解析器进行句法分析,输入函数处理输入和词法分析,符号表保存永久信息,驱动程序处理初始化、输出和错误。我们可以为这个计算器添加许多功能以使其更有用,但代码本身已经够长了,大多数功能只会增加代码,而不会提供有关 C++ 使用的更多见解。
10.2.1 解析器(Parser)
这是计算器接受的语言的语法:
program:
end //end is end-of-input
expr_list end
expr_list:
expression print // print 是换行或分号
expression print expr_list
expression:
expression + term
expression − term
term
term:
term / primary
term ∗ primary
primary
primary:
number //number 是一个浮点文字量
name //name是一个标识符
name = expression
− primary
( expression )
换句话说,程序是由分号分隔的一系列表达式。表达式的基本单位是数、名称和运算符 ∗、/、+、−(一元和二元)和 =(赋值)。名称在使用前无需声明。
我使用一种称为递归下降(recursive descent)的语法分析风格;它是一种流行且简单的自上而下技术。在 C++ 等函数调用相对廉价的语言中,它也是高效的。对于语法中的每个产生式,都有一个调用其他函数的函数。词法分析器识别终止符号(例如,结束符、数、+ 和 −),语法分析器函数 expr(),term() 和 prim() 识别非终止符号。只要知道(子)表达式的两个操作数,就会对表达式进行求值;在真正的编译器中,此时可以生成代码。
对于输入,解析器使用 Token_stream 来封装字符的读取并将其组合进Token。也就是说,Token_stream 会“标记化(tokenizes)”:它将字符流(例如 123.45)转换为Token。Token是类型和值构成的配对{标记类型,值},例如 {number,123.45},其中 123.45 已转换为浮点值。解析器的主要部分只需要知道 Token_stream 的名称 ts,以及如何从中获取Token。要读取下一个Token,它会调用 ts.get()。要获取最近读取的Token (“当前Token”),它会调用 ts.current()。除了提供Token化之外,Token_stream 还隐藏了字符的实际来源。我们将看到,它们可以直接来自用户输入到 cin、来自程序命令行或来自任何其他输入流(§10.2.7)。
Token的定义如下:
enum class Kind : char {
name, number, end,
plus='+', minus='−', mul='∗', div='/’, print=';', assign='=', lp='(', rp=')'
};
struct Token {
Kind kind;
string string_value;
double number_value;
};
用字符的整数值表示每个Token既方便又高效,并且可以帮助使用调试器的人。只要用作输入的字符没有用作枚举器的值,这种方法就有效——而且据我所知,目前没有字符集具有只有一位数整数值的打印字符。
Token_stream 的接口如下所示:
class Token_stream {
public:
Token get(); // read and return next token
const Token& current(); // most recently read token
// ...
};
实施方案见第 10.2.2 节。
每个解析器函数都接受一个 bool (§6.2.2) 参数,称为 get,指示函数是否需要调用 Token_stream::get() 来获取下一个Token。每个解析器函数都会评估“其”表达式并返回该值。函数 expr() 处理加法和减法。它由一个循环组成,该循环查找要加或减的项:
double expr(bool get) // + 和 -
{
double left = term(get);
for (;;)
{ // ‘‘forever’’
switch (ts.current().kind) {
case Kind::plus:
left += term(true);
break;
case Kind::minus:
left −= term(true);
break;
default:
return left;
}
}
}
这个函数本身实际上并没有做太多事情。它以大型程序中高级函数的典型方式调用其他函数来完成工作。
switch 语句(§2.2.4、§9.4.2)根据一组常量测试其条件的值(该值在 switch 关键字后的括号中提供)。break 语句用于退出 switch 语句。如果测试的值与任何 case 标签都不匹配,则选择默认值。程序员不需要提供默认值。
请注意,如语法中所述,诸如 2−3+4 之类的表达式被计算为 (2−3)+4。
奇怪的是符号 for(;;) 是一种指定无限循环的方法;您可以将其读作“forever”(§9.5);while(true) 是一种替代方法。switch 语句会重复执行,直到找到不同于 + 和 − 的内容,然后执行默认情况下的 return 语句。
运算符 += 和 −= 用于处理加法和减法;可以使用 left=left+term(true) 和 left=left−term(true),而不会改变程序的含义。但是 left+=term(true) 和 left−=term(true) 不仅更短,而且可以直接表达预期的操作。每个赋值运算符都是一个单独的词汇Token,因此 + = 1; 是一个语法错误,因为 + 和 = 之间有空格。
C++ 为二元运算符提供了赋值运算符:
+ − ∗ / % & | ˆ << >>
因此可以使用以下赋值运算符:
= += −= ∗= /= %= &= |= ˆ= <<= >>=
% 是取模或余数运算符;&、| 和 ˆ 是按位逻辑运算符 与、或 和非;<< 和 >> 是左移和右移运算符;§10.3 总结了这些运算符及其含义。对于应用于内置类型的操作数的二元运算符 @,表达式x@=y 表示 x=x@y,但 x 仅被求值一次。
函数 term() 处理乘法和除法的方式与 expr() 处理加法和减法的方式相同:
double term(bool get) // multiply and divide
{
double left = prim(get);
for (;;) {
switch (ts.current().kind) {
case Kind::mul:
left ∗= prim(true);
break;
case Kind::div:
if (auto d = prim(true)) {
left /= d;
break;
}
return error("divide by 0");
default:
return left;
}
}
}
除以零的结果是不确定的,通常会导致灾难性的后果。因此,我们在除法之前先测试是否为 0,如果检测到除数为零,则调用 error()。函数 error() 的描述见 §10.2.4。
变量 d 被引入到程序中需要它的位置并立即初始化。条件中引入的名称的范围是该条件控制的语句,结果值是条件的值(§9.4.3)。因此,当且仅当 d 非零时,才会执行除法和赋值 left/=d。
处理主元素的函数 prim() 与 expr() 和 term() 非常相似,不同之处在于,由于我们在调用层次结构中处于较低位置,因此需要完成一些实际工作,并且不需要循环:
double prim(bool get) // handle primaries
{
if (get) ts.get(); // read next token
switch (ts.current().kind) {
case Kind::number: // floating-point constant
{
double v = ts.current().number_value;
ts.get();
return v;
}
case Kind::name:
{
double& v = table[ts.current().string_value]; // find the corresponding
if (ts.get().kind == Kind::assign) v = expr(true); // ’=’ seen: assignment
return v;
}
case Kind::minus: // unary minus
return −prim(true);
case Kind::lp:
{
auto e = expr(true);
if (ts.current().kind != Kind::rp) return error("')' expected");
ts.get(); // eat ’)’
return e;
}
default:
return error("primary expected");
}
}
当看到一个数字(即整数或浮点文字)的Token时,其值将放置在其number_value中。同样,当看到一个名称(无论如何定义;参见§10.2.2和§10.2.3)的Token时,其值将放置在其string_value中。
请注意,prim() 读取的 Token 总是比用于分析其主要表达式的 Token 多。原因是它必须在某些情况下这样做(例如,查看是否分配了名称),因此为了保持一致性,它必须在所有情况下都这样做。在解析器函数只是想前进到下一个Token 的情况下,它不使用 ts.get() 的返回值。这很好,因为我们可以从 ts.current() 获得结果。如果忽略 get() 的返回值困扰着我,我要么添加一个 read() 函数,该函数只更新 current() 而不返回值,要么明确“丢弃”结果:void(ts.get())。
在对名称进行任何操作之前,计算器必须先查看该名称是否被赋值或只是读取。在这两种情况下,都会查阅符号表。符号表是一张地图(§4.4.3,§31.4.3):
map<string,double> table;
也就是说,当表通过字符串进行索引时,结果值是与该字符串对应的双精度值。例如,如果用户输入
radius = 6378.388;
计算器将到达 case Kind::name 并执行
double& v = table["radius"];
// ... expr() calculates the value to be assigned ...
v = 6378.388;
当 expr() 根据输入字符计算值 6378.388 时,引用 v 用于保存与半径关联的双精度值。
第 14 章和第 15 章讨论了如何将程序组织为一组模块。但是,除了一个例外,此计算器示例的声明可以按顺序排列,以便所有内容只声明一次,并在使用之前声明。例外是 expr(),它调用 term(),后者调用 prim(),后者又调用 expr()。必须以某种方式打破这种调用循环。声明
double expr(bool);
放在prim() 的定义之前更佳。
多见解。
10.2.2 输入(Input)
读取输入通常是程序中最混乱的部分。要与人交流,程序必须应对该人的各种想法、惯例和看似随机的错误。试图强迫该人以更适合机器的方式行事通常(正确地)被认为是冒犯。底层输入例程的任务是读取字符并从中组成上层Token (tokens)。这些Token是高级例程的输入单位。在这里,低级输入由 ts.get() 完成。编写低级输入例程不一定是日常任务。许多系统为此提供了标准功能。
首先我们需要看看Token_stream的完整定义:
class Token_stream {
public:
Token_stream(istream& s) : ip{&s}, owns{false} { }
Token_stream(istream∗ p) : ip{p}, owns{true} { }
˜Token_stream() { close(); }
Token get(); // read and return next token
Token& current(); // most recently read token
void set_input(istream& s) { close(); ip = &s; owns=false; }
void set_input(istream∗ p) { close(); ip = p; owns = true; }
private:
void close() { if (owns) delete ip; }
istream∗ ip; //指向输入流的指针
bool owns; // does the Token_stream own the istream?
Token ct {Kind::end} ; // current token
};
我们用输入流(§4.3.2,第 38 章)初始化Token_stream,它从中获取字符。Token_stream 实现了它拥有(并最终删除;§3.2.1.2,§11.2)作为指针传递的 istream 的约定,而不是作为引用传递的 istream 的约定。对于这个简单的程序来说,这可能有点复杂,但对于持有指向需要销毁的资源的指针的类来说,这是一种有用且通用的技术。
Token_stream 包含三个值:指向其输入流的指针 (ip),布尔值 (owns)(表示输入流的所有权)和当前Token (ct)。
我给 ct 指定了一个默认值,因为不这样做似乎有些草率。人们不应该在 get() 之前调用 current(),但如果他们这样做了,他们会得到一个定义明确的 Token。我选择 Kind::end 作为 ct 的初始值,这样误用 current() 的程序就不会得到输入流中没有的值。
我分两个阶段介绍 Token_stream::get()。首先,我提供了一个看似简单的版本,但会给用户带来负担。接下来,我将其修改为一个稍微不那么优雅但更容易使用的版本。get() 的思路是读取一个字符,使用该字符来决定需要组成哪种类型的 token,在需要时读取更多字符,然后返回一个表示读取的字符的 Token。
初始语句将 ∗ip(ip 指向的流)中的第一个非空白字符读入 ch 并检查读取操作是否成功:
Token Token_stream::g et()
{
char ch = 0;
∗ip>>ch;
switch (ch) {
case 0:
return ct={Kind::end}; // assign and return
默认情况下,运算符 >> 会跳过空白(即空格、制表符、换行符等),如果输入操作失败,则保留 ch 的值不变。因此,ch==0 表示输入结束。
赋值是一个运算符,赋值的结果是赋值的变量的值。这样我就可以将值 Kind::end 赋给 curr_tok 并在同一个语句中返回它。使用一个语句而不是两个语句在维护上很有用。如果赋值和返回在代码中分开,程序员可能会更新一个而忘记更新另一个。
还要注意赋值语句右侧如何使用 {}列表符号(§3.2.1.3、§11.3)。也就是说,它是一个表达式。我可以将这个返回语句写成:
ct.kind = Kind::end; // assign
return ct; // return
但是,我认为分配一个完整的对象 {Kind::end} 比处理 ct 的单个成员更清晰。{Kind::end} 相当于 {Kind::end,0,0}。如果我们关心 Token 的最后两个成员,那么这很好,但如果我们担心性能,那就不太好了。这里的情况都不是,但一般来说,处理完整的对象比单独操作数据成员更清晰,也更不容易出错。下面的案例给出了另一种策略的例子。
在考虑完整函数之前,请分别考虑一些情况。表达式终止符、';'、括号和运算符只需返回其值即可处理:
case ';': //表达式终止; print
case '∗':
case '/':
case '+':
case '−':
case '(':
case ')':
case '=':
return ct={static_cast<Kind>(ch)};
需要 static_cast (§11.5.2),因为没有从 char 到 Kind (§8.4.1) 的隐式转换;只有一些字符对应于 Kind 值,因此我们必须“证明”在这种情况下 ch 确实如此。
数处理如下:
case '0': case '1': case '2': case '3': case '4': case '5': case '6': case '7': case '8': case '9':case '.':
ip−>putback(ch); //将第一个数字 (或 .) 追加到输入流
∗ip >> ct.number_value; // 将数读入ct
ct.kind=Kind::number;
return ct;
水平而不是垂直堆叠case标签通常并不可取,因为这种安排更难阅读。但是,每个数字一行很乏味。因为运算符>>已经定义为将浮点值读入双精度数,所以代码很简单。首先将初始字符(数字或点)放回cin。然后,可以将浮点值读入ct.number_value。
如果标记不是输入的结尾、运算符、标点符号或数字,则它一定是名称。名称的处理方式与数字类似:
default: //name, name =, or error
if (isalpha(ch)) {
ip−>putback(ch); //将第一个字符追加到输入流
∗ip>>ct.string_value; //读入字符到到 ct
ct.kind=Kind::name;
return ct;
}
最后,我们可能只是遇到了错误。处理错误的简单但合理有效的方法是 write 调用 error() 函数,然后如果 error() 返回,则返回打印标记:
error("bad token");
return ct={Kind::print};
标准库函数 isalpha() (§36.2.1) 用于避免将每个字符列为单独的 case 标签。应用于字符串(在本例中为 string_value)的运算符 >> 会一直读取直到遇到空格。因此,用户必须在使用名称作为操作数的运算符之前用空格终止名称。这并不理想,因此我们将在 §10.2.3 中返回这个问题。
最后,这是完整的输入函数:
Token Token_stream::g et()
{
char ch = 0;
∗ip>>ch;
switch (ch) {
case 0:
return ct={Kind::end}; // assign and return
case ';': // end of expression; print
case '∗':
case '/':
case '+':
case '−':
case '(':
case ')':
case '=':
return ct=={static_cast<Kind>(ch)};
case '0': case '1': case '2': case '3': case '4': case '5': case '6': case '7': case '8': case '9':
case '.':
ip−>putback(ch); //put the first digit (or .) back into the input stream
∗ip >> ct.number_value; // read number into ct
ct.kind=Kind::number;
return ct;
default: //name, name =, or error
if (isalpha(ch)) {
ip−>putback(ch); //put the first character back into the input stream
∗ip>>ct.string_value; // read string into ct
ct.kind=Kind::name;
return ct;
}
error("bad token");
return ct={Kind::print};
}
}
将运算符转换为其Token值很简单,因为运算符的类型被定义为运算符的整数值(§10.2.1)。
10.2.3 底层输入(Low-Level Input)
使用目前定义的计算器会发现一些不便之处。为了打印出表达式的值,记住在表达式后添加分号是一件很繁琐的事情,而仅以空格结尾的名称也是一件很麻烦的事情。例如,x=7 是一个标识符 - 而不是标识符 x 后跟运算符 = 和数字 7。为了得到我们(通常)想要的结果,我们必须在 x 后添加空格:x =7。这两个问题都可以通过用读取单个字符的代码替换 get() 中面向类型的默认输入操作来解决。
首先,我们将使用与标记表达式结束的分号等同的换行符:
Token Token_stream::g et()
{
char ch;
do { // skip whitespace except ’\n’
if (!ip−>get(ch)) return ct={Kind::end};
} while (ch!='\n' && isspace(ch));
switch (ch) {
case ';':
case '\n':
return ct={Kind::print};
这里,我使用 do 语句;它相当于 while 语句,只是受控语句总是至少执行一次。调用 ip−>get(ch) 从输入流 ∗ip 读入一个字符到 ch。默认情况下,get() 不会像 >> 那样跳过空格。如果无法从 cin 读取任何字符,则测试 if (!ip−>get(ch)) 成功;在这种情况下,返回 Kind::end 以终止计算器会话。使用运算符 !(非)是因为 get() 在成功的情况下返回 true。
标准库函数 isspace() 提供了对空白的标准测试(§36.2.1);如果 c 是空白字符,isspace(c) 将返回非零值,否则返回零。该测试以表查找的形式实现,因此使用 isspace() 比测试单个空白字符要快得多。类似的函数测试字符是数字(isdigit())、字母(isalpha())还是数字或字母(isalnum())。
跳过空格后,下一个字符用于确定接下来是什么类型的词汇token。
通过每次读取一个字符,直到找到非字母或数字的字符,可以解决由 >> 读入字符串直到遇到空格而引起的问题:
default: //NAME, NAME=, or error
if (isalpha(ch)) {
string_value = ch;
while (ip−>get(ch) && isalnum(ch))
string_value += ch; // 追加 ch 到 string_value 尾
ip−>putback(ch);
return ct={Kind::name};
}
幸运的是,这两项改进都可以通过修改单个本地代码段来实现。构建程序以便仅通过本地修改即可实现改进是一个重要的设计目标。
您可能会担心将字符逐个添加到字符串末尾会效率低下。对于非常长的字符串,情况确实如此,但所有现代字符串实现都提供了“小字符串优化”(§19.3.3)。这意味着处理我们可能在计算器(甚至编译器)中用作名称的字符串类型不会涉及任何低效操作。特别是,使用短字符串不需要使用任何空闲存储空间。短字符串的最大字符数取决于实现,但 14 个字符是一个不错的猜测。
10.2.4 错误处理(Error-Handling)
检测和报告错误始终很重要。但是,对于此程序,一个简单的错误处理策略就足够了。error() 函数只是计数错误,写出错误消息,然后返回:
int no_of_errors;
double error(const string& s)
{
no_of_errors++;
cerr << "error: " << s << '\n';
return 1;
}
流 cerr 是一个无缓冲输出流,通常用于报告错误(§38.1)。
返回值的原因是错误通常发生在表达式求值过程中,因此我们要么完全中止该求值,要么返回一个不太可能导致后续错误的值。后者对于这个简单的计算器来说已经足够了。如果 Token_stream::get() 跟踪行号,error() 就可以告知用户错误发生的大致位置。当计算器以非交互方式使用时,这将非常有用。
更程式化和更通用的错误处理策略是将错误检测与错误恢复分开。这可以使用异常来实现(参见第 13 章 §2.4.3.1),但我们这里的方法非常适合 180 行计算器。
10.2.5 驱动器(Driver)
程序的所有部分都准备就绪后,我们只需要一个驱动程序即可启动程序。我决定使用两个函数:main() 用于设置和错误报告,calculate() 用于处理实际计算:
Token_stream ts {cin}; // use input from cin
void calculate()
{
for (;;) {
ts.get();
if (ts.current().kind == Kind::end) break;
if (ts.current().kind == Kind::print) continue;
cout << expr(false) << '\n';
}
}
int main()
{
table["pi"] = 3.1415926535897932385; // insert predefined names
table["e"] = 2.7182818284590452354;
calculate();
return no_of_errors;
}
按照惯例,如果程序正常终止,main() 将返回零,否则返回非零(§2.2.1)。返回错误数可以很好地实现这一点。实际上,唯一需要的初始化就是将预定义名称插入符号表。
主循环(在 calculate() 中)的主要任务是读取表达式并写出答案。这通过以下代码实现:
cout << expr(false) << '\n';
参数 false 告诉 expr() 它不需要调用 ts.get() 来读取要处理的token。
测试 Kind::end 可确保当 ts.get() 遇到输入错误或文件结尾时正确退出循环。break 语句退出其最近的封闭 switch 语句或循环(§9.5)。测试 Kind::print(即 '\n' 和 ';')可减轻 expr() 处理空表达式的责任。continue 语句相当于转到循环的最后。
10.2.6 头文件(Headers)
计算器使用标准库工具。因此,必须 #included 适当的标头才能完成程序:
#include<iostream> // I/O
#include<string> // strings
#include<map> // map
#include<cctype> // isalpha(), etc.
所有这些标头都在 std 命名空间中提供了便利,因此要使用它们提供的名称,我们必须使用 std:: 进行显式限定,或者通过以下方式将名称带入全局命名空间:
using namespace std;
为了避免将表达式的讨论与模块化问题相混淆,我选择了后者。第 14 章和第 15 章讨论了如何使用命名空间将此计算器组织成模块以及如何将其组织成源文件。
10.2.7 命令行参数
编写并测试完程序后,我发现首先启动程序,然后输入表达式,最后退出很麻烦。我最常用的是评估单个表达式。如果该表达式可以作为命令行参数呈现,则可以避免一些击键。
程序通过调用 main()(§2.2.1,§15.4)开始。调用 main() 时,main() 会获得两个参数,指定参数的数量(通常称为 argc)和一个参数数组(通常称为 argv)。参数是 C 风格的字符串(§2.2.5,§7.3),因此 argv 的类型为 char∗[argc+1]。程序的名称(出现在命令行中)作为 argv[0] 传递,因此 argc 始终至少为 1。参数列表以零结尾;即 argv[argc]==0。例如,对于命令
dc 150/1.1934
参数具有这些值:
由于调用 main() 的约定与 C 共享,因此使用 C 风格的数组和字符串。
这个思想是从命令字符串中读取,就像我们从输入流中读取一样。从字符串中读取的流毫不奇怪地被称为 istringstream(§38.2.2)。因此,要计算命令行上显示的表达式,我们只需让 Token_stream 从适当的 istringstream 中读取:
Token_stream ts {cin};
int main(int argc, char∗ argv[])
{
switch (argc) {
case 1: // read from standard input
break;
case 2: // read from argument string
ts.set_input(new istringstream{argv[1]});
break;
default:
error("too many arguments");
return 1;
}
table["pi"] = 3.1415926535897932385; // insert predefined names
table["e"] = 2.7182818284590452354;
calculate();
return no_of_errors;
}
要使用 istringstream,请包含<sstream>。
修改 main() 以接受多个命令行参数是很容易的,但这似乎没有必要,特别是因为可以将多个表达式作为单个参数传递:
dc "rate=1.1934;150/rate;19.75/rate;217/rate"
我使用引号是因为“ ; ”是我的 UNIX 系统上的命令分隔符。其他系统在启动时向程序提供参数时有不同的约定。
尽管 argc 和 argv 很简单,但它们仍然是一些小而烦人的错误的来源。为了避免这些问题,尤其是为了更容易传递程序参数,我倾向于使用一个简单的函数来创建一个 vector<string>:
vector<string> arguments(int argc, char∗ argv[])
{
vector<string> res;
for (int i = 0; i!=argc; ++i)
res.push_back(argv[i]);
return res;
}
更复杂的参数解析函数并不罕见。
10.2.8 风格说明
对于不熟悉关联数组的程序员来说,使用标准库映射作为符号表几乎像是作弊。但事实并非如此。标准库和其他库都是用来使用的。通常,一个库在设计和实现上比程序员在一个程序中使用一段手工编写的代码所花费的精力要多得多。
查看计算器的代码,尤其是第一个版本,我们可以看到没有太多传统的 C 风格,底层代码。许多传统的棘手细节已被标准库类(如 ostream、string 和 map)的使用所取代(§4.3.1,§4.2,§4.4.3,§31.4,第 36 章,第 38 章)。
请注意循环、算术和赋值相对较少。在不直接操作硬件或实现底层抽象的代码中,事情就应该如此。
10.3 运算符概述(Operator Summary)
本节介绍表达式的摘要和一些示例。每个运算符后面都跟有一个或多个常用名称以及其使用示例。这些表中:
• 名称是标识符(例如 sum 和 map)、运算符名称(例如,operator int、operator+ 和 operator"" km)或模板特化(译注:具体化)的名称(例如,sort<Record> 和 array<int,10>),可能使用 :: 限定(例如,std::vector 和 vector<T>::operator[])。
• 类名是类的名称(包括 decltype(expr),其中 expr 表示类)。
• 成员是成员名称(包括析构函数或成员模板的名称)。
• 对象是产生类对象的表达式。
• 指针是产生指针的表达式(包括此指针和支持指针操作的该类型的对象)。
• expr 是表达式,包括文字量(例如,17、"mouse" 和 true) 。
• 表达式列表是表达式的列表(可能为空)。
• 左值是一个表示可修改对象的表达式(§6.4.1)。
• 类型只有在括号中出现时才可以是完全通用的类型名称(带有 ∗、() 等);
在其他地方,有限制(§iso.A)。
• lambda声明符是一个(可能为空、以逗号分隔的)参数列表,后面跟可选可变说明符,再其后跟可选 noexcept 说明符,再其后跟可选返回类型(§11.4)。
• capture列表 是一个(可能为空的)列表,指定上下文依赖关系(§11.4)。
• stmt列表是一个(可能为空的)语句列表(§2.2.4,第 9 章)。
表达式的语法与操作数类型无关。这里介绍的含义适用于操作数为内置类型的情况(§6.2.1)。此外,您还可以定义应用于用户定义类型的操作数的运算符的含义(§2.3,第 18 章)。
表格只能近似描述语法规则。有关详细信息,请参阅§iso.5 和 §iso.A。
表中每个框包含具有相同优先级的运算符。较高框中的运算符具有更高的优先级。例如,N::x.m 表示 (N::m).m,而不是非法的 N::(x.m)。
例如,后缀 ++ 的优先级高于一元 ∗,因此 ∗p++ 表示 ∗(p++),而不是 (∗p)++。
例如:a+b∗c 表示 a+(b∗c) 而不是 (a+b)∗c,因为 ∗ 的优先级高于 +。
一元运算符和赋值运算符是右结合的;而所有其他运算符都是左结合的。例如,a=b=c 表示 a=(b=c),而 a+b+c 表示 (a+b)+c。
一些语法规则无法用优先级(也称为约束强度)和结合性来表达。例如,a=b<c?d=e:f=g 表示 a=((b<c)?(d=e):(f=g)),但您需要查看语法(§iso.A)来确定这一点。
在应用语法规则之前,词汇标记由字符组成。选择最长的字符序列来制作标记。例如,&& 是一个运算符,而不是两个 & 运算符,而 a+++1 表示 (a ++) + 1。这有时被称为 Max Munch 规则。
空格字符(例如空格、制表符和换行符)可以是标记分隔符(例如,int count 是一个关键字,后跟标识符,而不是 intcount),但在其他情况下会被忽略。
基本源字符集 (§6.1.2) 中的某些字符(例如 |)不方便输入某些关键字。此外,一些程序员发现使用诸如 && 和 ˜ 之类的符号进行基本逻辑运算很奇怪。因此,提供了一组替代表示作为关键字:
例如,
bool b = not (x or y) and z;
int x4 = ˜ (x1 bit or x2) bit and x3;
等价于
bool b = !(x || y) && z;
int x4 = ˜(x1 | x2) & x3;
请注意,and= 不等同于 &=;如果您更喜欢关键字,则必须写 and_eq。
10.3.1 结果(Results)
算术运算符的结果类型由一组称为“常规算术转换”的规则决定(§10.5.3)。总体目标是产生“最大”操作数类型的结果。例如,如果二元运算符具有浮点操作数,则使用浮点算术进行计算,结果为浮点值。同样,如果它具有 long 操作数,则使用长整数算术进行计算,结果为长整型。小于 int 的操作数(例如 bool 和 char)在应用运算符之前转换为 int。
关系运算符 ==、<= 等产生bool结果。用户定义运算符的含义和结果类型由其声明决定(§18.2)。
只要逻辑上可行,采用左值操作数的运算符的结果就是表示该左值操作数的左值。例如:
void f(int x, int y)
{
int j = x = y; // 在赋值之后,x=y 的值是 x 的值
int∗ p = &++x; //p指向x
int∗ q = &(x++); // 错 : x++不是左值 (并非值存于x)
int∗ p2 = &(x>y?x:y); // address of the int with the larger value
int& r = (x<y)?x:1; // error : 1 is not an lvalue
}
如果 ?: 的第二个和第三个操作数都是左值且具有相同的类型,则结果属于该类型且为左值。以这种方式保留左值可提高运算符的使用灵活性。这在编写需要统一高效地处理内置类型和用户定义类型的代码时尤其有用(例如,编写生成 C++ 代码的模板或程序时)。
sizeof 的结果是一个无符号整数类型(译注:总是大于等于0),名为 size_t,定义在 <cstddef> 中。指针减法的结果是一个有符号整数类型,名为 ptrdiff_t,定义在 <cstddef> 中。
实现不必检查算术溢出,而且几乎没有实现会这样做。例如:
void f()
{
int i = 1;
while (0 < i) ++i;
cout << "i has become negative!" << i << '\n';
}
这将(最终)尝试将 i 增加到超过最大整数。然后会发生什么情况尚不确定,但通常该值会“回绕”为负数(在我的机器上为 -2147483648)。同样,除以零的效果也是未定义的,但这样做通常会导致程序突然终止。特别是,下溢、溢出和除以零不会引发标准异常(§30.4.1.1)。
10.3.2 计算顺序(Order of Evaluation)
表达式中子表达式的求值顺序是不确定的。特别是,您不能假设表达式是从左到右求值的。例如:
int x = f(2)+g(3); // 不确定 f() 和 g() 哪个先调用
在没有限制表达式求值顺序的情况下,可以生成更好的代码。但是,没有限制求值顺序可能会导致未定义的结果。例如:
int i = 1;
v[i] = i++; // 不明确的结果
赋值可能计算为 v[1]=1 或 v[2]=1,或者可能导致一些更奇怪的行为。编译器可以警告此类歧义。不幸的是,大多数编译器不会这样做,因此请注意不要编写多次读取或写入对象的表达式,除非它使用使其明确定义的单个运算符(例如 ++ 和 +=)进行此操作,或者使用 “,”(逗号)、&& 或 || 明确表达排序。
运算符“,”(逗号)、&&(逻辑与)和 ||(逻辑或)保证其左侧操作数先于右侧操作数进行求值。例如,b=(a=2,a+1) 将 3 赋值给 b。在 §10.3.3 中可以找到 || 和 && 的使用示例。对于内置类型,&& 的第二个操作数只有当其第一个操作数为真时才会进行求值,而 || 的第二个操作数只有当其第一个操作数为假时才会进行求值;这有时称为短路求值。请注意,排序运算符 , (逗号)在逻辑上不同于函数调用中用于分隔参数的逗号。例如:
f1(v[i],i++); // two arguments
f2( (v[i],i++) ); // one argument
f1 的调用有两个参数,v[i] 和 i++,并且参数表达式的求值顺序是未定义的。因此应该避免这种情况。参数表达式的顺序依赖性非常糟糕,并且具有未定义的行为。f2 的调用只有一个参数,即逗号表达式 (v[i],i++),它等同于 i++。这很令人困惑,因此也应该避免这种情况。
括号可用于强制分组。例如,a∗b/c 表示 (a∗b)/c,因此必须使用括号才能得到 a∗(b/c);只有当用户无法区分时,a∗(b/c) 才可以被评估为 (a∗b)/c。特别是,对于许多浮点计算,a∗(b/c) 和 (a∗b)/c 有很大不同,因此编译器将完全按照书写方式计算此类表达式。
10.3.3 运算符优先级(Operator Precedence)
优先级和结合性规则反映了最常见的用法。例如:
if (i<=0 || max<i) // ...
表示“如果 i 小于或等于 0,或者 max 小于 i。”也就是说,它相当于
if ( (i<=0) || (max<i) ) // ...
这不合法,而且荒谬
if (i <= (0||max) < i) // ...
但是,当程序员对这些规则有疑问时,应该使用括号。随着子表达式变得越来越复杂,使用括号变得越来越普遍,但复杂的子表达式是错误的来源。因此,如果您开始觉得需要使用括号,您可以考虑使用额外的变量来分解表达式。
在某些情况下,运算符优先级不会导致“明显”的解释。例如:
if (i&mask == 0) // oops! == expression as operand for &
这不会对 i 应用掩码,然后测试结果是否为零。由于 == 的优先级高于 &,因此表达式被解释为 i&(mask==0)。幸运的是,编译器很容易对大多数此类错误发出警告。在这种情况下,括号很重要:
if ((i&mask) == 0) // ...
值得注意的是,以下情况并不按数学家预期的方式进行:
if (0 <= x <= 99) // ...
这是合法的,但它被解释为 (0<=x)<=99,其中第一次比较的结果为真或假。然后,这个布尔值被隐式转换为 1 或 0,然后与 99 进行比较,结果为真。要测试 x 是否在 0..99 范围内,我们可以使用
if (0<=x && x<=99) // ...
新手常犯的一个错误是在条件中使用 = (赋值)而不是 == (等于):
if (a = 7) // oops! 在条件句中进行常量赋值
这是很自然的,因为 = 在许多语言中表示“等于”。同样,编译器很容易对大多数此类错误发出警告——而且许多编译器确实会这样做。我不建议为了补偿编译器的弱警告而改变你的风格。特别是,我认为这种风格不值得:
if (7 == a) // 试图对误用=进行保护; 不推荐
10.3.4 临时对象(Temporary Objects)
通常,编译器必须引入一个对象来保存表达式的中间结果。例如,对于 v=x+y∗z,y∗z 的结果必须先放在某个地方,然后再添加到 x。对于内置类型,这一切都经过处理,因此临时对象(通常称为临时对象)对用户(译注:对程序员)是不可见的。但是,对于保存资源的用户定义类型,了解临时对象的生命周期可能很重要。除非绑定到引用或用于初始化命名对象,否则临时对象会在创建它的完整表达式的末尾被销毁。完整表达式是某个表达式,它不是其他表达式的子表达式。
标准库string有一个成员 c_str() (§36.3),它返回一个指向以零结尾的字符数组 (§2.2.5、§43.4) 的 C 风格指针。此外,运算符 + 被定义为字符串连接。这些对于string来说是有用的功能。但是,将它们组合在一起可能会导致难以察觉的问题。例如:
void f(string& s1, string& s2, string& s3)
{
const char∗ cs = (s1+s2).c_str();
cout << cs;
if (strlen(cs=(s2+s3).c_str())<8 && cs[0]=='a') {
// cs used here
}
}
可能你的第一反应是“但不要这样做!”且我同意。然而,这样的代码确实有人写过,所以了解它是如何解释的还是值得的。
创建一个临时字符串对象来保存 s1+s2。接下来,从该对象中提取指向 C 风格字符串的指针。然后——在表达式的末尾——删除临时对象。但是,c_str() 返回的 C 风格字符串是作为保存 s1+s2 的临时对象的一部分分配的,并且不能保证在临时对象被销毁后该存储仍然存在。因此,cs 指向已释放的存储。输出操作 cout<<cs 可能按预期工作,但这纯粹是运气。编译器可以检测并警告此问题的许多变体。
if 语句的问题更加微妙。条件将按预期工作,因为创建临时变量 s2+s3 的完整表达式就是条件本身。但是,该临时变量在输入受控语句之前就被销毁了,因此,在那里使用 cs 并不能保证一定有效。
请注意,在这种情况下,与许多其他情况一样,临时变量的问题是由于以底层方式使用上层数据类型而引起的。更简洁的编程风格会产生更易于理解的程序片段,并完全避免临时变量的问题。例如:
void f(string& s1, string& s2, string& s3)
{
cout << s1+s2;
string s = s2+s3;
if (s.length()<8 && s[0]=='a') {
// use s here
}
}
临时变量可用作 const 引用或命名对象的初始化器。例如:
void g(const string&, const string&);
void h(string& s1, string& s2)
{
const string& s = s1+s2;
string ss = s1+s2;
g(s,ss); // we can use s and ss here
}
这没问题。当“其”引用或命名对象超出范围时,临时对象将被销毁。请记住,返回对局部变量的引用是错误的(§12.1.4),并且临时对象不能绑定到非常量左值引用(§7.7)。
还可以通过调用构造函数(§11.5.1)在表达式中显式创建临时对象。例如:
void f(Shape& s, int n, char ch)
{
s.move(string{n,ch}); //构造一个包含n个ch 副本的字符串,传递给 Shape::move()
// ...
}
此类临时变量的销毁方式与隐式生成的临时变量完全相同。
10.4 常量表达式
C++ 提供了两种相关含义的“常量”:
• constexpr:在编译时求值(§2.2.3)(译注:即编译时确定其值,而不是运行时计算)。
• const:请勿在此范围内修改(§2.2.3,§7.5)(译注:表明作用域内不可改,并不表明它是一个常量,当然,可以在初始化时赋值为一个常量)。
基本上,constexpr 的作用是启用和确保编译时求值,而 const 的主要作用是指定接口中的不变性。本节主要关注第一个作用:编译时求值。
常量表达式是编译器可以求值的表达式。它不能使用编译时未知的值,也不能产生副作用。最终,常量表达式必须以整数值(§6.2.1)、浮点值(§6.2.5)或枚举器(§8.4)开头,我们可以使用运算符和 constexpr 函数将它们组合起来,进而产生值。此外,某些地址可以用于某些形式的常量表达式中。为简单起见,我将在 §10.4.5 中单独讨论这些内容。
人们可能想要一个命名常量而不是文字量或存储在变量中的值,原因有多种:
[1] 命名常量使代码更易于理解和维护。
[2] 变量可能会改变(因此我们在推理时必须比推理常量时更加谨慎)。
[3] 该语言需要数组大小、case 标签和模板值参数的常量表达式。
[4] 嵌入式系统程序员喜欢将不可变数据放入只读存储器,因为只读存储器比动态存储器更便宜(就成本和能耗而言),而且通常更丰富。此外,只读存储器中的数据不受大多数系统崩溃的影响。
[5] 如果在编译时完成初始化,则在多线程系统中该对象上不会出现数据争用。
[6] 有时,一次估算(在编译时)比在运行时执行一百万次估算的性能要好得多(译注:因为在编译时耗时多一点并没有多大的关系,如果能够使编译结果达到最优,这点代价是值得的)。
请注意,原因 [1]、[2]、[5] 和(部分)[4] 是合乎逻辑的。我们使用常量表达式不仅仅是因为对性能的执着。通常,原因是常量表达式更直接地表示了我们的系统要求。
作为数据项定义的一部分(这里我故意避免使用“变量”这个词),constexpr 表示需要编译时求值。如果 constexpr 的初始化器无法在编译时求值,则编译器会给出错误。例如:
int x1 = 7;
constexpr int x2 = 7;
constexpr int x3 = x1; // 错: 初始化器不是常量表达式
constexpr int x4 = x2; // OK /* 译注:若没有 constexpr 修饰,则此为一个赋值操作, 在执行计算,而constexpr修饰下,在编译时把 x4 编译成 //成常量 7 */
void f()
{
constexpr int y3 = x1; //错: 初始化器不是常量表达式
constexpr int y4 = x2; // OK
// ...
}
(译注:注意看下面语句在编译后的区别:
int x1 = 7;
00007FF748B7288D mov dword ptr [x1],7 ;当成常量
int x3 = x1;
00007FF748B72895 mov eax,dword ptr [x1]
00007FF748B72899 mov dword ptr [x3],eax ;当成赋值处理
constexpr int x2 = 7;
00007FF748B7289D mov dword ptr [x2],7 ;当成常量
constexpr int x4 = x2; // OK (译注:若没有 constexpr 修饰,则此当成赋值处理
00007FF748B728A5 mov dword ptr [x4],7 ;上面这行代码当常量处理,这就是区别
)
聪明的编译器可以推断出 x3 的初始化器中 x1 的值为 7。但是,我们不想依赖编译器的聪明程度。在大型程序中,在编译时确定变量的值通常非常困难或不可能。
常量表达式的表达能力非常强大。我们可以使用整数,浮点数和枚举值。我们可以使用任何不修改状态的运算符(例如 +、?: 和 [],但不能使用 = 或 ++)。我们可以使用 constexpr 函数(§12.1.6)和文字量类型(§10.4.3)来提供相当高水平的类型安全性和表达能力。将其与通常使用宏(§12.6)进行对比几乎是不公平的。
条件表达式运算符 ?: 是常量表达式中的选择手段。例如,我们可以在编译时计算整数平方根:
constexpr int isqrt_helper(int sq, int d, int a)
{
return sq <= a ? isqrt_helper(sq+d,d+2,a) : d;
}
constexpr int isqrt(int x)
{
return isqrt_helper(1,3,x)/2 − 1;
}
constexpr int s1 = isqrt(9); // s1 becomes 3 (译注:编译时 s1 被确定为 3 )
constexpr int s2 = isqrt(1234);
先估算 ?: 的条件,然后评估所选的替代方案。未选择的替代方案不会被评估,甚至可能不是常量表达式。同样,未评估的 && 和 || 的操作数不一定是常量表达式。此功能主要用于 constexpr 函数,这些函数有时用作常量表达式,有时则不用作常量表达式。
10.4.1 符号常量(Symbolic Constants)
常量(constexpr 或 const 值)最重要的单一用途就是为值提供符号名称。应系统地使用符号名称,以避免代码中出现“幻数(magic numbers)”。代码中随意散布的文字量值是最严重的维护隐患之一。如果代码中重复出现数字常量(例如数组绑定),则很难修改该代码,因为必须更改该常量的每次出现才能正确更新代码。使用符号名称可以本地化信息。通常,数字常量表示对程序的假设。例如,4 可能表示整数中的字节数,128 表示缓冲输入所需的字符数,6.24 表示丹麦克朗和美元之间的汇率因子。如果这些值在代码中保留为数字常量,维护人员很难发现和理解它们。此外,许多这样的值需要随着时间的推移而改变。通常,这些数值会被忽视,当程序被移植或当其他更改违反它们所代表的假设时,它们就会变成错误。将假设表示为注释良好的命名(符号)常量可以最大限度地减少此类维护问题。
10.4.2 常量表达式中的const
const 主要用于表达接口(§7.5)。但是,const 也可用于表达常量值。例如:
const int x = 7;
const string s = "asdf";
const int y = sqrt(x);
用常量表达式初始化的const可以在常量表达式中使用。const与 constexpr 的不同之处在于,const可以由非常量表达式初始化;在这种情况下,const 不能用作常量表达式。例如:
constexpr int xx = x; // OK,x 是常量
constexpr string ss = s; // 错 : s 不是常量表达式
constexpr int yy = y; // 错: sqr t(x) 不是常量表达式,因此间接的赋值不可
错误的原因是 string 不是文字量类型 (§10.4.3) 并且 sqrt() 不是 constexpr 函数 (§12.1.6)。
通常,对于定义简单常量,constexpr 比 const 更好,但 constexpr 是 C++11 中的新特性,因此较旧的代码倾向于使用 const。在许多情况下,枚举器(§8.4)是 const 的另一种替代方案。
10.4.3 文字量类型(Literal Types)
足够简单的用户定义类型可用于常量表达式。例如:
struct Point {
int x,y,z;
constexpr Point up(int d) { return {x,y,z+d}; }
constexpr Point move(int dx, int dy) { return {x+dx,y+dy}; }
// ...
};
具有 constexpr 构造函数的类称为文字量类型。为了足够简单以成为 constexpr,构造函数必须有一个空体,并且所有成员都必须由潜在常量表达式初始化。例如:
constexpr Point origo {0,0};
constexpr int z = origo.x;
constexpr Point a[] = {
origo, Point{1,1}, Point{2,2}, origo.move(3,3)
};
constexpr int x = a[1].x; // x becomes 1
constexpr Point xy{0,sqrt(2)}; // 错: sqr t(2) 不是常量表达式
请注意,我们可以拥有 constexpr 数组,也可以访问数组元素和对象成员。
当然,我们可以定义 constexpr 函数来接受文字量类型的参数。例如:
constexpr int square(int x)
{
return x∗x;
}
constexpr int radial_distance(Point p)
{
return isqrt(square(p.x)+square(p.y)+square(p.z));
}
constexpr Point p1 {10,20,30}; // 默认构造函数是constexpr
constexpr p2 {p1.up(20)}; // Point::up() 是 constexpr
constexpr int dist = radial_distance(p2);
我使用 int 而不是 double 只是因为我手边没有 constexpr 浮点平方根函数。
对于成员函数 constexpr 意味着 const,所以我不必写:
constexpr Point move(int dx, int dy) const { return {x+dx,y+dy}; }
10.4.4 引用参数(Reference Arguments)
使用 constexpr 时,要记住的关键一点是 constexpr 完全与值有关。这里没有可以更改值或产生副作用的对象:constexpr 提供了一种微型编译时函数式编程语言。话虽如此,您可能会猜测 constexpr 无法处理引用,但这只是部分正确,因为 const 引用引用值,因此可以使用。考虑将通用 complex<T> 特化为标准库中的 complex<double>:
template<> class complex<double> {
public:
constexpr complex(double re = 0.0, double im = 0.0);
constexpr complex(const complex<float>&);
explicit constexpr complex(const complex<long double>&);
constexpr double real(); // read the real part
void real(double); // set the real part
constexpr double imag(); // read the imaginary par t
void imag(double); // set the imaginary par t
complex<double>& operator= (double);
complex<double>& operator+=(double);
// ...
};
显然,修改对象的操作(例如 = 和 +=)不能是 constexpr。相反,仅读取对象的操作(例如 real() 和 imag())可以是 constexpr,并在给定常量表达式的情况下在编译时进行评估。有趣的成员是来自另一个complex类型的模板构造函数。考虑:
constexpr complex<float> z1 {1,2}; // note: <float> not <double>
constexpr double re = z1.real();
constexpr double im = z1.imag();
constexpr complex<double> z2 {re,im}; // z2 becomes a copy of z1
constexpr complex<double> z3 {z1}; // z3 becomes a copy of z1
复制构造函数之所以有效,是因为编译器识别出引用(const complex<float>&)指的是一个常量值,我们只需使用该值(而不是尝试使用引用或指针进行任何高级或愚蠢的操作)。
文字量类型允许进行类型丰富的编译时编程。传统上,C++ 编译时求值仅限于使用整数值(并且不使用函数)。这导致代码不必要地复杂且容易出错,因为人们将每种信息都编码为整数。模板元编程(第 28 章)的一些用途就是例子。其他程序员只是更喜欢运行时求值,以避免用贫乏的语言编写的困难。
10.4.5 地址常量表达式(Address Constant Expressions)
静态分配的对象(§6.4.2),例如全局变量,其地址是一个常量。但是,它的值是由链接器而不是编译器分配的,因此编译器无法知道这种地址常量的值。这限制了指针和引用类型的常量表达式的范围。例如:
constexpr const char∗ p1 = "asdf";
constexpr const char∗ p2 = p1; // OK
constexpr const char∗ p2 = p1+2; // 错 : 编译器并不知晓 p1 的值
constexpr char c = p1[2]; // OK, c==’d’; 编译器知晓 p1 所指向的值
10.5 隐式类型转换(Implicit Type Conversion)
整数和浮点类型(§6.2.1)可以在赋值和表达式中自由混合。只要有可能,就会转换值以免丢失信息。不幸的是,一些破坏值的(“窄化(narrowing))”)转换也会隐式执行。如果您可以转换一个值,然后将结果转换回其原始类型并获取原始值,则转换是保值的。如果转换不能做到这一点,它就是窄化转换(§10.5.2.6)。本节提供了转换规则,转换问题及其解决方案的描述。
10.5.1 提升(Promotions)
保值的隐式转换通常称为提升。在执行算术运算之前,整数提升用于从较短的整数类型创建整数。类似地,浮点提升用于从浮点数创建双精度数。请注意,这些提升不会提升为长整型(除非操作数是 char16_t、char32_t、wchar_t 或已经大于 int 的普通枚举)或长整型双精度数。这反映了这些提升在 C 中的最初目的:将操作数提升到算术运算的“自然”大小。
整数提升是:
• 如果 int 可以表示源类型的所有值,则将 char、signed char、unsigned char、short int 或 unsigned short int 转换为 int;否则,将其转换为 unsigned int。
• 将 char16_t、char32_t、wchar_t (§6.2.3) 或普通枚举类型 (§8.4.2) 转换为以下可以表示其基础类型的所有值的类型中的第一个:int、unsigned int、long、unsigned long 或 unsigned long long。
• 如果 int 可以表示位域的所有值,则将位域 (§8.2.7) 转换为 int;否则,如果 unsigned int 可以表示位域的所有值,则将其转换为 unsigned int。否则,不对其进行任何整数提升。
• 将 bool 转换为 int;false 变为 0,true 变为 1。
提升是通常的算术转换的一部分(§10.5.3)。
10.5.2 转换(Conversions)
基本类型可以以多种方式隐式地相互转换(§iso.4)。在我看来,允许的转换太多了。例如:
void f(double d)
{
char c = d; // 警惕: 双精度浮点数到char的转换
}
编写代码时,您应该始终避免未定义的行为和悄悄丢弃信息的转换(“窄化转换”)。
编译器可以对许多可疑的转换发出警告。幸运的是,许多编译器都会这样做。
{}初始化器语法可防止窄化(§6.3.5)。例如:
void f(double d)
{
char c {d}; // 错 :双精度浮点数到 char 的转换
}
如果无法避免潜在的窄化转换,请考虑使用某种形式的运行时检查转换函数,例如 narrow_cast<>() (§11.5)。
10.5.2.1 整数转换
整数可以转换为另一种整数类型。普通枚举值可以转换为整数类型(§8.4.2)。
如果目标类型是无符号的,则结果值就是源中适合目标的位数(必要时会丢弃高位)。更确切地说,结果是与源整数模 2 的 n 次方一致的最小无符号整数,其中 n 是用于表示无符号类型的位数。例如:
unsigned char uc = 1023;// 二进制 1111111111: uc 成二进制 11111111, 即, 255
如果目标类型是有符号的,则如果该值可以在目标类型中表示,则该值不变;否则,该值是由实现定义的:
signed char sc = 1023; //由实现定义
合理的结果是 127 和 −1 (§6.2.3)。
bool或纯枚举值可以隐式转换为其整数等价值(§6.2.2,§8.4)。
10.5.2.2 浮点转换
浮点值可以转换为另一个浮点类型。如果源值可以在目标类型中准确表示,则结果为原始数值。如果源值位于两个相邻的目标值之间,则结果为其中一个值。否则,行为未定义。例如:
float f = FLT_MAX; // largest float value
double d = f; // OK: d == f
double d2 = DBL_MAX; // largest double value
float f2 = d2; // 若 FLT_MAX<DBL_MAX 则未定义
long double ld = d2; // OK: ld = d3
long double ld2 = numeric_limits<long double>::max();
double d3 = ld2; //若 sizeof(long double)>sizeof(double) 则未定义
DBL_MAX 和 FLT_MAX 在 <climits> 中定义;numeric_limits 在 <limits> (§40.2) 中定义。
10.5.2.3 指针和引用转换
任何指向对象类型的指针都可以隐式转换为void∗(§7.2.1)。指向派生类的指针(引用)可以隐式转换为指向可访问且无歧义的基类的指针(引用)(§20.2)。请注意,指向函数的指针或指向成员的指针不能隐式转换为 void∗。
计算结果为 0 的常量表达式 (§10.4) 可以隐式转换为任何指针类型的空指针。同样,计算结果为 0 的常量表达式可以隐式转换为指向成员的指针类型 (§20.6)。例如:
int∗ p = (1+2)∗(2∗(1−1)); // OK, 但怪异
优先使用 nullptr (§7.2.2)。
T∗ 可以隐式转换为 const T∗ (§7.5)。类似地,T& 可以隐式转换为 const T&。
10.5.2.4 指向成员的指针转换(Pointer-to-Member Conversion)
指向成员的指针和引用可以按照§20.6.3 中的描述进行隐式转换。
10.5.2.5 bool值转换(Boolean Conversions)
指针、整数和浮点值可以隐式转换为bool值(§6.2.2)。非零值转换为 true;零值转换为 false。例如:
void f(int∗ p, int i)
{
bool is_not_zero = p; // true if p!=0
bool b2 = i; // true if i!=0
// ...
}
指针到bool值的转换在条件下很有用,但在其他方面却令人困惑:
void fi(int);
void fb(bool);
void ff(int∗ p, int∗ q)
{
if (p) do_something(∗p); //OK
if (q!=nullptr) do_something(∗q); // OK, 但冗长
// ...
fi(p); //错: 不存在指针向 int 的转换
fb(p); //OK: 指针向 bool 值的转换 (诧异!?)
}
希望编译器对 fb(p) 发出警告。
10.5.2.6 浮点数到整数的转换
当浮点值转换为整数值时,小数部分会被丢弃。换句话说,从浮点类型转换为整数类型会截断。例如,int(1.6) 的值为 1。如果截断的值无法在目标类型中表示,则行为未定义。例如:
int i = 2.7; // i becomes 2
char b = 2000.7; // 对于8位 chars 未定义: 2000 不能表示为 8位 char
从整数到浮点类型的转换在数学上尽可能正确,只要硬件允许。如果整数值不能精确地表示为浮点类型的值,就会发生精度损失。例如:
int i = float(1234567890);
在使用 32 位表示整数和浮点数的机器上,i 的值为 1234567936。
显然,最好避免可能破坏值的隐式转换。事实上,编译器可以检测并警告一些明显危险的转换,例如浮点到整数和长整型到字符。然而,一般的编译时检测是不切实际的,所以程序员必须小心。当“小心”还不够时,程序员可以插入显式检查。例如:
char checked_cast(int i)
{
char c = i; // warning: not portable (§10.5.2.1)
if (i != c) throw std::runtime_error{"int−to−char check failed"};
return c;
}
void my_code(int i)
{
char c = checked_cast(i);
// ...
}
§25.2.5.1 中介绍了表达已检查转换的更通用的技术。
要以保证可移植的方式截断,需要使用 numeric_limits (§40.2)。在初始化中,可以使用 {}初始化器符号 (§6.3.5) 来避免截断。
10.5.3 常见算术转换(Usual Arithmetic Conversions)
这些转换是在二元运算符的操作数上执行的,以将它们转换为通用类型,然后将其用作结果的类型:
[1] 如果其中一个操作数是 long double 类型,则另一个操作数将转换为 long double 类型。
• 否则,如果其中一个操作数是double,则另一个操作数将转换为double。
• 否则,如果其中一个操作数是float,则另一个操作数将转换为float。
• 否则,对两个操作数都执行整数提升(§10.5.1)。
[2] 否则,如果任一操作数为unsigned long long,则另一个操作数将转换为unsigned long long。
• 否则,如果一个操作数是 long long int 而另一个是 unsigned long int,则如果 long long int 可以表示 unsigned long int 的所有值,则 unsigned long int 将被转换为 long long int;否则,两个操作数都将转换为 unsigned long long int。否则,如果其中一个操作数是 unsigned long long,则另一个操作数将被转换为 unsigned long long。
• 否则,如果一个操作数是 long int 而另一个是 unsigned int,则如果 long int 可以表示 unsigned int 的所有值,则 unsigned int 将被转换为 long int;否则,两个操作数都将转换为 unsigned long int。
• 否则,如果其中一个操作数是 long,则另一个操作数将被转换为 long。
• 否则,如果其中一个操作数是 unsigned,则另一个操作数将被转换为 unsigned。
• 否则,两个操作数都是 int。
这些规则使得将无符号整数转换为可能更大的有符号整数的结果由实现定义。这也是避免混合无符号整数和有符号整数的另一个原因。
10.6 建议
[1] 优先使用标准库,而不是其他库和“手工编写的代码”;§10.2.8。
[2] 仅在必要时使用字符级输入;§10.2.3。
[3] 阅读时,始终考虑格式错误的输入;§10.2.3。
[4] 优先使用合适的抽象(类、算法等),而不是直接使用语言特性(例如,int、语句);§10.2.8。
[5] 避免使用复杂的表达式;§10.3.3。
[6] 如果对运算符优先级有疑问,请加括号;§10.3.3。
[7] 避免使用未定义求值顺序的表达式;§10.3.2。
[8] 避免窄化转换;§10.5.2。
[9] 定义符号常量以避免“魔法常量(magic constants)”;§10.4.1。
内容来源:
<<The C++ Programming Language >> 第4版,作者 Bjarne Stroustrup