第 12 章 函数(Function)
目录
12.1.6.2 条件计算(Conditional Evaluation)
12.3 重载函数(Overloaded Functions)
12.3.1 引用参数(Automatic Overload Resolution)
12.3.2 重载与返回值(Overloading and Return Type)
12.3.3 重载与作用域(Overloading and Scope)
12.3.4 多参数解析(Resolution for Multiple Arguments)
12.3.5 手动重载解析(Manual Overload Resolution)
12.4 前置和后置条件(Pre- and Postconditions)
12.5 指向函数的指针(Pointer to Function)
12.6 宏(宏指令)(大指令)(Macros——macroinstruction)
12.6.1 条件编译(Conditional Compilation)
12.6.2 预定义宏指令(Predefined Macros)
12.1 函数声明
在 C++ 程序中完成某项工作的主要方式是调用函数。定义函数是指定如何执行操作的方式。除非事先声明过函数,否则无法调用该函数。
函数声明给出了函数的名称、返回值的类型(如果有)以及调用时必须提供的参数的数量和类型。例如:
Elem∗ next_elem(); // no argument; return an Elem*
void exit(int); // int argument; return nothing
double sqrt(double); // double argument; return a double
参数传递的语义与复制初始化的语义相同(§16.2.6)。检查参数类型,并在必要时进行隐式参数类型转换。例如:
double s2 = sqrt(2); // call sqrt() with the argument double{2}
double s3 = sqrt("three"); // error : sqr t() requires an argument of type double
这种检查和类型转换的价值不容低估。
函数声明可能包含参数名称。这可以帮助程序的读者,但除非声明的同时定义函数,否则编译器会忽略这些名称。作为返回类型,void 表示函数不返回值(§6.2.7)。
函数类型由返回类型和参数类型组成。对于类成员函数(§2.3.2,§16.2),类的名称也是函数类型的一部分。例如:
double f(int i, const Info&); // type: double(int,const Info&)
char& String::operator[](int); // type: char& String::(int)
12.1.1 为什么需要函数?
编写很长的函数(数百行)是一种长期且声名狼藉的传统。我曾经遇到过一个(手写的)函数,其代码超过 32,768 行。编写此类函数的人似乎未能理解函数的主要目的之一:将复杂的计算分解为有意义的块并命名。我们希望我们的代码易于理解,因为这是实现可维护性的第一步。实现可理解性的第一步是将计算任务分解为可理解的块(表示为函数和类)并命名这些块。然后,此类函数提供了计算的基本词汇,就像类型(内置和用户定义)提供了数据的基本词汇一样。C++ 标准算法(例如 find,sort 和 iota)提供了一个良好的开端(第 32 章)。接下来,我们可以将表示常见或专门任务的函数组合成更大的计算。
代码中的错误数量与代码量和代码复杂度密切相关。使用更多和更短的函数可以解决这两个问题。使用函数执行特定任务通常可以使我们免于在其他代码中间编写特定代码;使其成为函数会迫使我们命名活动并记录其依赖关系。此外,函数调用和返回使我们免于使用容易出错的控制结构,例如 goto(§9.6)和 continue(§9.5.5)。除非结构非常规则,否则嵌套循环是可以避免的错误来源(例如,使用点积来表达矩阵算法而不是嵌套循环;§40.6)。
最基本的建议是保持函数的大小,以便您可以在屏幕上完整地查看它。当我们一次只能查看算法的一部分时,错误往往会悄悄出现。对于许多程序员来说,这将函数限制在 40 行左右。我的理想是更小的尺寸,也许平均 7 行。
基本上在所有情况下,函数调用的成本都不是重要因素。如果成本可能很高(例如,对于经常使用的访问函数,如vector下标),则内联可以消除成本(§12.1.5)。因此,使用函数作为结构化机制。
12.1.2 函数声明的组成成分
除了指定名称、一组参数和返回类型之外,函数声明还可以包含各种指定符和修饰符。我们总共可以有:
• 函数名称;必需
• 参数列表,可以为空();必需
• 返回类型,可以为 void,也可以为前缀或后缀(使用 auto);必需
• inline,表示希望通过内联函数体来实现函数调用(§12.1.5)
• constexpr,表示如果给定常量表达式作为参数,则应该能够在编译时估算函数(§12.1.6)
• noexcept,表示函数不得抛出异常(§13.5.1.1)
• 链接规范,例如 static(§15.2)
• [[noreturn]],表示函数不会使用正常调用/返回机制返回(§12.1.4)此外,成员函数可以指定为:
• virtual,表示它可以在派生类中被覆盖(§20.3.2)
• override,表示它必须覆盖基类中的虚函数(§20.3.4.1)
• final,表示它不能在派生类中被覆盖(§20.3.4.2)
• static,表示它不与特定对象关联(§16.2.12)
• const,表示它不能修改其对象(§3.2.1.1,§16.2.9.1)
如果你想让读者头疼,你可以写类似的东西:
struct S {
[[noreturn]] virtual inline auto f(const unsigned long int ∗const) −> void const noexcept;
};
12.1.3 函数定义
调用的每个函数都必须在某处定义(仅一次;§15.2.3)。函数定义是指在其中呈现了函数主体的函数声明。例如:
void swap(int∗, int∗); //声明
void swap(int∗ p, int∗ q) //定义
{
int t = ∗p;
∗p = ∗q;
∗q = t;
}
函数的定义和所有声明都必须指定相同的类型。遗憾的是,为了保持 C 兼容性,在参数类型的最高层级上会忽略 const。例如,这是同一函数的两个声明:
void f(int); // type is void(int)
void f(const int); // type is void(int)
该函数 f() 可以定义为:
void f(int x) { /*we can modify x here */ }
或者,我们可以将 f() 定义为:
void f(const int x) { /*we cannot modify x here */ }
无论哪种情况,f() 可以或不可以修改的参数都是调用者提供的副本,因此不存在调用上下文被模糊修改的危险。
函数参数名称不是函数类型的一部分,并且在不同的声明中不需要相同。例如:
int& max(int& a, int& b, int& c); // return a reference to the larger of a, b, and c
int& max(int& x1, int& x2, int& x3)
{
return (x1>x2)? ((x1>x3)?x1:x3) : ((x2>x3)?x2:x3);
}
在非定义的声明中命名参数是可选的,通常用于简化文档。相反,我们可以通过不命名来表明函数定义中未使用某个参数。例如:
void search(table∗ t, const char∗ key, const char∗)
{
// no use of the third argument
}
通常,未命名参数的产生源于代码的简化或提前规划扩展。在这两种情况下,保留参数(尽管未使用)可确保调用者不受更改的影响。
除了函数之外,我们还可以调用一些其他的东西;它们遵循为函数定义的大多数规则,例如参数传递规则(§12.2):
• 构造函数(§2.3.2,§16.2.5)从技术上来说不是函数;具体来说,它们不返回
值,可以初始化基类和成员(§17.4),并且不能获取它们的地址。
• 析构函数(§3.2.1.2,§17.2)不能重载,也不能获取它们的地址。
• 函数对象(§3.4.3,§19.2.2)不是函数(它们是对象)并且不能重载,但它们的运算符()是函数。
• Lambda 表达式(§3.4.3,§11.4)基本上是定义函数对象的简写。
12.1.4 函数返回值
每个函数声明都包含函数返回类型的规范(构造函数和类型转换函数除外)。在传统上,在 C 和 C++ 中,返回类型位于函数声明的开头(在函数名称之前)。但是,函数声明也可以使用按返回类型放在参数列表之后的语法来编写。例如,以下两个声明是等价的:
string to_string(int a); // 前缀返回类型
auto to_string(int a) −> string; // 后缀返回类型
也就是说,前缀 auto 表示返回类型放在参数列表之后。后缀返回类型前面带有 −>。
后缀返回类型的基本用途在于函数模板声明,其中返回类型取决于参数。例如:
template<class T, class U>
auto product(const vector<T>& x, const vector<U>& y) −> decltype(x∗y);
但是,后缀返回语法可以用于任何函数。函数的后缀返回语法与 lambda 表达式语法(§3.4.3,§11.4)有明显的相似之处;遗憾的是这两个构造并不相同。
不返回值的函数的“返回类型”为 void。
必须从未声明为 void 的函数返回值(但是,main() 比较特殊;请参阅 §2.2.1)。相反,void 函数不能返回值。例如:
int f1() { } // 错 :未返回值
void f2() { } // OK
int f3() { return 1; } // OK
void f4() { return 1; } // 错: 在void 函数中返回值
int f5() { return; } // 错:丢失返回值
void f6() { return; } // OK
返回值由 return语句指定。例如:
int fac(int n)
{
return (n>1) ? n∗fac(n−1) : 1;
}
调用自身的函数被称为递归函数(recursive)。
一个函数中可以有多个返回语句:
int fac2(int n)
{
if (n > 1)
return n∗fac2(n−1);
return 1;
}
与参数传递的语义一样,函数值返回的语义与复制初始化的语义相同(§16.2.6)。返回语句初始化返回类型的变量。将根据返回类型的类型检查返回表达式的类型,并执行所有标准和用户定义的类型转换。例如:
double f() { return 1; } // 1 被隐匿地转化为double{1}
每次调用函数时,都会创建其参数和局部(自动)变量的新副本。函数返回后,该存储会被重用,因此指向局部非static变量的指针永远不应返回。指向位置的内容将不可预测地发生变化:
int∗ fp()
{
int local = 1;
// ...
return &local; // 灾难
}
使用引用时可能会发生等效错误:
int& fr()
{
int local = 1;
// ...
return local; // 灾难
}
幸运的是,编译器可以轻松地警告返回对局部变量的引用(大多数编译器都会这样做)。
没有 void 值。但是,void 函数的调用可以用作 void 函数的返回值。例如:
void g(int∗ p);
void h(int∗ p)
{
// ...
return g(p); // OK:等效于 ‘‘g(p); return;’’
}
这种返回形式对于避免在编写返回类型为模板参数的模板函数时出现特殊情况很有用。
return语句是退出函数的五种方式之一:
• 执行return语句。
• “触及函数尾”返回;即,仅到达函数体的末尾。这只允许在未声明返回值的函数(即 void 函数)和 main() 中发生,其中触及函数尾表示函数成功执行完成(§12.1.4)。
• 引发未在局部捕获的异常(§13.5)。
• 由于在 noexcept 函数中引发异常且未在局部捕获而终止(§13.5.1.1)。
• 直接或间接调用不返回的系统函数(例如 exit();§15.4)。
不能正常返回的函数(即通过return或“触尾返回”)可以标记为 [[noreturn]] (§12.1.7)。
12.1.5 inline函数(内联函数)
可以将函数定义为inline函数。例如:
inline int fac(int n)
{
return (n<2) ? 1 : n∗fac(n−1);
}
inline说明符提示编译器,它应该尝试为 fac() 调用生成代码,而不是先为函数编写代码,然后通过通常的函数调用机制进行调用。聪明的编译器可以为调用 fac(6) 生成常量 720。相互递归的内联函数、根据输入递归或不递归的内联函数等的可能性使得无法保证对内联函数的每次调用实际上都是内联的。编译器的聪明程度无法规定,因此一个编译器可能会生成 720,另一个编译器可能会生成 6∗fac(5),还有一个编译器可能会生成未内联的调用 fac(6)。如果您希望保证在编译时计算某个值,请将其声明为 constexpr,并确保在其求值中使用的所有函数都是 constexpr(§12.1.6)。
为了在没有异常巧妙的编译和链接工具的情况下实现内联,内联函数的定义(而不仅仅是声明)必须在作用域内(§15.2)。内联说明符不会影响函数的语义。特别是,内联函数仍然具有唯一的地址,内联函数的静态变量(§12.1.8)也是如此。
如果内联函数在多个编译单元中定义(例如,通常是因为它在标题中定义;§15.2.2),则其在不同编译单元中的定义必须相同(§15.2.3)。
12.1.6 constexpr函数(常量表达式函数)
一般来说,函数无法在编译时求值,因此无法在常量表达式中调用(§2.2.3,§10.4)。通过指定函数为constexpr,我们表明如果给定常量表达式作为参数,我们希望该函数可以在常量表达式中使用。例如:
constexpr int fac(int n)
{
return (n>1) ? n∗fac(n−1) : 1;
}
constexpr int f9 = fac(9); // 一定会在编译时进行估算(并生成常量)
当 constexpr 用于函数定义时,它的意思是“当给定常量表达式作为参数时,应该可以在常量表达式中使用”。当用于对象定义时,它的意思是“在编译时估算初始化器”。例如:
void f(int n)
{
int f5 = fac(5); // 可以在编译时估算
int fn = fac(n); // 运行时计算 (n 是变量)
constexpr int f6 = fac(6); // 一定会在编译时进行估算
constexpr int fnn = fac(n); // 错: 不能保证编译时估算 (n 变量)
char a[fac(4)]; // OK:数组边界必需是常量且fac()是constexpr
char a2[fac(n)]; // 错: 数组边界必须是常量,并且 n 是变量
// ...
}
要在编译时进行评估,函数必须足够简单:constexpr 函数必须由单个返回语句组成;不允许循环和局部变量。此外,constexpr 函数不得有副作用。也就是说,constexpr 函数是纯函数。例如:
int glob;
constexpr void bad1(int a) // 错: constexpr 函数不能是void
{
glob = a; // 错: constexpr函数中的副作用
}
constexpr int bad2(int a)
{
if (a>=0) return a; else return −a; // 错: constexpr 函数中用if语句
}
constexpr int bad3(int a)
{
sum = 0; // 错: constexpr 函数中使用局部变量
for (int i=0; i<a; +=i) sum +=fac(i); // 错: constexpr函数中使用loop
return sum;
}
constexpr 构造函数的规则有适当不同(§10.4.3);在那里,只允许对成员进行简单的初始化。
constexpr 函数允许递归和条件表达式。这意味着,如果您真的愿意,您可以将几乎任何东西表达为 constexpr 函数。但是,除非您将 constexpr 函数的使用限制在其预期的相对简单的任务上,否则您会发现调试变得不必要地困难,并且编译时间比您希望的要长。
通过使用文字量类型(§10.4.3),可以将 constexpr函数定义为使用用户定义类型。
与内联函数一样,constexpr 函数也遵循 ODR(“单一定义规则”),因此不同编译单元中的定义必须相同(§15.2.3)。您可以将 constexpr 函数视为内联函数的受限形式(§12.1.5)。
12.1.6.1 constexpr和引用
constexpr 函数不能有副作用,因此无法写入非局部对象。但是,constexpr 函数可以引用非本局部对象,只要它不写入它们即可。
constexpr int ftbl[] { 1, 2, 3, 5, 8, 13 };
constexpr int fib(int n)
{
return (n<sizeof(ftbl)/siz eof(∗ftbl)) ? ftbl[n] : fib(n);
}
constexpr 函数可以接受引用参数。当然,它不能通过这样的引用写入,但 const 引用参数仍然很有用。例如,在标准库 (§40.4) 中,我们发现:
template<> class complex<float> {
public:
// ...
explicit constexpr complex(const complex<double>&);
// ...
};
这使得我们可以写成:
constexpr complex<float> z {2.0};
逻辑上构造的用于保存 const 引用参数的临时变量仅仅成为编译器内部的一个值。
constexpr 函数可以返回引用或指针。例如:
constexpr const int∗ addr(const int& r) { return &r; } // OK
然而,这样做会让我们偏离 constexpr 函数作为常量表达式求值的一部分的基本作用。特别是,确定此类函数的结果是否为常量表达式可能非常棘手。请考虑:
static const int x = 5;
constexpr const int∗ p1 = addr(x); // OK
constexpr int xx = ∗p1; //OK
static int y;
constexpr const int∗ p2 = addr(y); // OK
constexpr int yy = ∗y; //错: 尝试读取一个变量
constexpr const int∗ tp = addr(5); // 错: 临时地址
12.1.6.2 条件计算(Conditional Evaluation)
constexpr 函数中未采用的条件表达式的分支不会被求值。这意味着未采用的分支可能需要运行时求值。例如:
constexpr int check(int i)
{
return (low<=i && i<high) ? i : throw out_of_rang e();
}
constexpr int low = 0;
constexpr int high = 99;
// ...
constexpr int val = check(f(x,y,z));
您可能会认为 low 和 high 是编译时已知但在设计时未知的配置参数,并且 f(x,y,z) 会计算一些与实现相关的值。
12.1.7 [[noreturn]]函数
结构 [[...]] 称为属性,可以放置在 C++ 语法中的任何位置。一般来说,属性指定一些与实现相关的属性,这些属性与它之前的语法实体有关。此外,属性可以放在声明前面。只有两个标准属性(§iso.7.6),[[noreturn]] 就是其中之一。另一个是 [[carries_dependency]](§41.3)。
将 [[noreturn]] 放在函数声明的开头表示该函数不返回。例如:
[[noreturn]] void exit(int); // exit永远不会返回
知道函数不返回对于理解和代码生成都很有用。
12.1.8 局部变量
函数中定义的名称通常称为局部名称。当执行线程到达其定义时,将初始化局部变量或常量。除非声明为static,否则每次调用函数时都会有自己的变量副本。如果将局部变量声明为static,则将使用单个静态分配的对象(§6.4.2)在函数的所有调用中表示该变量。它只会在执行线程第一次到达其定义时初始化(译注:后续再次进行调用不会再进行初始化,而是会在上一次对其操作的基础上改变其值,虽然它是声明的局部变量,但这个值的变化好似全局变量)。例如:
void f(int a)
{
while (a−−) {
static int n = 0; // 初始化一次
int x = 0; // 每次进入都初始化一次
cout << "n == " << n++ << ", x == " << x++ << '\n';
}
}
int main()
{
f(3);
}
输出:
n == 0, x == 0
n == 1, x == 0
n == 2, x == 0
static局部变量允许函数在调用之间保存信息,而无需引入可能被其他函数访问和破坏的全局变量(另请参阅§16.2.12)(译注:但这并意味着在多线程环境下是安全的)。
static局部变量的初始化不会导致数据竞争(§5.3.1),除非您以递归方式进入包含该变量的函数或发生死锁(§iso.6.7)。也就是说,C++ 实现必须使用某种无锁构造(例如,call_once;§42.3.3)来保护局部static变量的初始化。以递归方式初始化局部static变量的效果未定义。例如:
int fn(int n)
{
static int n1 = n; // OK
static int n2 = fn(n−1)+1; // undefined
return n;
}
static局部变量有助于避免非局部变量之间的顺序依赖性(§15.4.1)。
不存在局部函数;如果您觉得需要,请使用函数对象或 lambda 表达式(§3.4.3、§11.4)。
如果您愚蠢地使用标签,则标签的作用域(§9.6)是完整的函数,与它处于哪个嵌套作用域无关。
12.2 参数传递
当调用一个函数时(使用后缀 (),称为调用运算符或应用运算符),“store”(译注:应指对形参保留的栈空间)被留作其形式参数(formal arguments,简单形参)(也称为其参数),并且每个形参都由其对应的实际参数初始化。参数传递的语义与初始化的语义相同(准确地说是复制初始化;§16.2.6)。特别是,实际参数的类型会根据相应形式参数的类型进行检查,
并且会执行所有标准和用户定义的类型转换。除非形式参数(参数)是引用,否则会将实际参数的副本传递给函数。例如:
int∗ find(int∗ first, int∗ last, int v) // find x in [first:last)
{
while (first!=last && ∗first!=v)
++first;
return first;
}
void g(int∗ p, int∗ q)
{
int∗ pp = find(p,q,'x');
// ...
}
这里,调用者的参数 p 副本不会被 find() 副本上的操作(首先调用)所修改。指针本身是通过值传递的(译注:指针本质上是一个整数,存储的是内存地址,其占用内存大小一般等于处理器的位数,例如x64处理器下是8字节大小)。
传递数组有特殊规则(§12.2.2),传递未经检查的参数有便利(§12.2.4),指定默认参数也有便利(§12.2.5)。初始化列表的使用在§12.2.3 中描述,而将参数传递给模板函数的方法在§23.5.2 和 §28.6.2 中描述。
12.2.1 引用参数
考虑:
void f(int val, int& ref)
{
++val;
++ref;
}
当调用 f() 时,++val 会增加第一个实际参数的局部副本,而 ++ref 会增加第二个实际参数。考虑:
void g()
{
int i = 1;
int j = 1;
f(i,j);
}
调用 f(i,j) 将增加 j 但不增加 i。第一个参数 i 通过值传递;第二个参数 j 通过引用传递。如 §7.7 中所述,修改通过引用调用的参数的函数会使程序难以阅读,因此通常应避免使用(但请参阅 §18.2.5)。但是,通过引用传递大对象比通过值传递大对象效率更高。在这种情况下,可以将参数声明为 const 引用,以表明该引用仅用于提高效率,而不是使被调用函数能够更改对象的值:
void f(const Large& arg)
{
// the value of ‘‘arg’’ cannot be changed
// (except by using explicit type conversion; §11.5)
}
引用参数声明中没有 const 被视为修改变量的意图声明:
void g(Large& arg); // assume that g() modifies arg
类似地,将指针参数声明为 const 会告诉读者该参数指向的对象的值不会被函数改变。例如:
int strlen(const char∗); //number of characters in a C-style string
char∗ strcpy(char∗ to, const char∗ from); // copy a C-style string
int strcmp(const char∗, const char∗); //compare C-style strings
使用 const 参数的重要性随着程序的大小而增加。
请注意,参数传递的语义与赋值的语义不同。这对于 const 参数、引用参数和某些用户定义类型的参数非常重要。
遵循引用初始化规则,文字量、常量和需要转换的参数可以作为 const T& 参数传递,但不能作为普通(非 const)T& 参数传递。允许const T& 参数转换可确保此类参数可以通过按临时值传递值(如有必要)获得与 T 参数完全相同的值集。例如:
float fsqrt(const float&); // 取引用参数的Fortran风格sqrt
void g(double d)
{
float r = fsqrt(2.0f); // 传引用至临存储2.0f
r = fsqr t(r); // 传引用至r
r = fsqr t(d); // 传引用至临时存储static_cast<float>(d)
}
禁止对非常量引用参数进行转换(§7.7)可避免因引入临时变量而导致的愚蠢错误。例如:
void update(float& i);
void g(double d, float r)
{
update(2.0f); // 错: 常量参数
update(r); // 传引用至 r
update(d); //错: 要求类型转换
}
如果允许这些调用,update() 会悄悄更新临时变量,并立即删除。通常,这对程序员来说是一个不愉快的惊喜。
如果我们想要精确的话,传递引用就是传递左值引用,因为函数也可以接受右值引用。如 §7.7 所述,右值可以绑定到右值引用(但不能绑定到左值引用),左值可以绑定到左值引用(但不能绑定到右值引用)。例如:
void f(vector<int>&); // (non-const) 左值引用参数
void f(const vector<int>&); // const左值引用参数
void f(vector<int>&&); //参值引用参数
void g(vector<int>& vi, const vector<int>& cvi)
{
f(vi); //call f(vector<int>&)
f(vci); //call f(const vector<int>&)
f(vector<int>{1,2,3,4}); // call f(vector<int>&&);
}
我们必须假设函数将修改右值参数,使其只适合于销毁或重新赋值(§17.5)。右值引用最明显的用途是定义移动构造函数和移动赋值(§3.3.2、§17.5.2)。我相信有人会找到 const右值引用参数的巧妙用法,但到目前为止,我还没有看到真正的用例。
请注意,对于模板参数 T,模板参数类型推导规则赋予 T&& 与类型 X 的 X&& 截然不同的含义(§23.5.2.1)。对于模板参数,右值引用最常用于实现“完备转交”(§23.5.2.1,§28.6.3)。
我们如何选择传递参数的方式?我的经验法则是:
[1] 对小对象使用按值传递。
[2] 使用按const引用传递来传递不需要修改的大值。
[3] 将结果作为返回值返回,而不是通过参数修改对象。
[4] 使用右值引用来实现移动(§3.3.2,§17.5.2)和转交(§23.5.2.1)。
[5] 如果“无对象”是有效替代方案,则传递指针(并用 nullptr 表示“无对象”)。
[6] 仅在必要时才使用按引用传递。
最后一条经验法则中的“当必要时”指的是,传递指针通常是一种比使用引用更清晰的处理需要修改的对象(§7.7.1,§7.7.4)的机制。
12.2.2 数组参数
如果将数组用作函数参数,则会传递指向其起始元素的指针(译注:即当成指针传递,只不过传递的是一个常量指针——数组起始元素的地址)。例如:
int strlen(const char∗);
void f()
{
char v[] = "Annemarie";
int i = strlen(v);
int j = strlen("Nicholas");
}
也就是说,类型为 T[] 的参数在作为参数传递时将转换为 T∗。这意味着对数组参数元素的赋值会改变参数数组元素的值。换句话说,数组与其他类型的不同之处在于数组不是按值传递的。相反,传递的是指针(按指针的值)。
数组类型的参数相当于指针类型的参数。例如:
void odd(int∗ p);
void odd(int a[]);
void odd(int buf[1020]);
这三个声明是等效的,声明的是同一个函数。通常,参数名称不会影响函数的类型(§12.1.3)。传递多维数组的规则和技巧可以在§7.4.3 中寻得。
被调用函数无法获得数组的大小。这是错误的主要来源,但有几种方法可以解决这个问题。C 风格字符串以零结尾,因此可以计算其大小(例如,通过可能昂贵的 strlen() 调用;§43.4)。对于其他数组,可以传递指定大小的第二个参数。例如:
void compute1(int∗ vec_ptr, int vec_size); // one way
充其量,这只是一种解决方法。通常最好传递对某个容器的引用,例如vector(§4.4.1,§31.4),array(§34.2.1)或 map(§4.4.3,§31.4.3)。
如果您确实想传递一个数组,而不是容器或指向数组第一个元素的指针,则可以声明一个引用数组类型的参数。例如:
void f(int(&r)[4]);
void g()
{
int a1[] = {1,2,3,4};
int a2[] = {1,2};
f(a1); // OK
f(a2); // 错 : 元数数量不匹配
}
请注意,元素的数量是数组引用类型的一部分。这使得此类引用远不如指针和容器(如向量)灵活。数组引用的主要用途是在模板中,然后推断元素的数量。例如:
template<class T, int N> void f(T(&r)[N])
{
// ...
}
int a1[10];
double a2[100];
void g()
{
f(a1); // T is int; N is 10
f(a2); // T is double; N is 100
}
这通常会导致产生与具有不同数组类型的 f() 调用一样多的函数定义。
多维数组比较复杂(参见 §7.3),但通常可以使用指针数组代替,并且不需要特殊处理。例如:
const char∗ day[] = {"mon", "tue", "wed", "thu", "fri", "sat", "sun"};
与以往一样,vector和类似类型是内置、底层数组和指针的替代品。
12.2.3 参数列表
以 {} 分隔的列表可用作以下类型参数的参数:
[1] 类型 std::initializer_list<T>,其中列表的值可以隐式转换为 T ;
[2] 可以使用列表中提供的值初始化的类型;
[3] 对 T 数组的引用,其中列表的值可以隐式转换为 T 。
从技术上讲,案例 [2] 涵盖了所有示例,但我发现分别考虑这三种情况更容易。考虑:
template<class T>
void f1(initializer_list<T>);
struct S {
int a;
string s;
};
void f2(S);
template<class T, int N>
void f3(T (&r)[N]);
void f4(int);
void g()
{
f1({1,2,3,4}); // T is int and the initializer_list has size() 4
f2({1,"MKS"}); // f2(S{1,"MKS"})
f3({1,2,3,4}); // T is int and N is 4
f4({1}); // f4(int{1});
}
如果可能存在歧义,则initializer_list参数优先。例如:
template<class T>
void f(initializer_list<T>);
struct S {
int a;
string s;
};
void f(S);
template<class T, int N>
void f(T (&r)[N]);
void f(int);
void g()
{
f({1,2,3,4}); // T is int and the initializer_list has size() 4
f({1,"MKS"}); // calls f(S)
f({1}); // T is int and the initializer_list has size() 1
}
具有initializer_list参数的函数优先的原因是,如果根据列表元素的数量选择不同的函数,可能会非常混乱。不可能消除重载解析中的所有混乱形式(例如,参见§4.4,§17.3.4.1),但为 {} 列表参数赋予initializer_list参数的优先级似乎可以最大限度地减少混乱。
如果作用域中有一个带有initializer_list参数的函数,但参数列表不匹配,则可以选择另一个函数。调用 f({1,"MKS"}) 就是一个例子。
请注意,这些规则仅适用于std::initializer_list<T> 参数对于 std::initializer_list<T>& 或其他恰好被称为初始化列表的类型(在其他作用域中),没有特殊规则。
12.2.4 未指定参数数量
对于某些函数,无法指定调用中预期的所有参数的数量和类型。要实现此类接口,我们有三种选择:
[1] 使用可变参数模板 (§28.6):这使我们能够以类型安全的方式处理任意数量的任意类型,方法是编写一个小型模板元程序来解释参数列表以确定其含义并采取适当的操作。
[2] 使用initializer_list作为参数类型 (§12.2.3)。这使我们能够以类型安全的方式处理任意数量的单一类型的参数。在许多情况下,这种同类列表是最常见和最重要的情况。
[3] 用省略号 (...) (译注:3个点号)终止参数列表,这意味着“可能还有一些参数”。这使我们能够通过使用来自 <cstdarg> 的一些宏来处理任意数量的(几乎)任意类型。此解决方案本身不是类型安全的,并且很难与复杂的用户定义类型一起使用。但是,这种机制从 C 语言的早期就开始使用了。
前两种机制已在其他地方描述,因此我以下仅描述第三种机制(尽管我认为对于大多数用途来说,它不如其他机制)。例如:
int printf(const char∗ ...);
这指定标准库函数 printf() (§43.3) 的调用必须至少有一个参数,即C 风格字符串,但可能有或可能没有其他参数。例如:
printf("Hello, world!\n");
printf("My name is %s %s\n", first_name , second_name);
printf("%d + %d = %d\n",2,3,5);
此类函数必须依赖编译器在解释其参数列表时无法获得的信息。对于 printf(),第一个参数是包含特殊字符序列的格式字符串,这些字符序列允许 printf() 正确处理其他参数;%s 表示“期望一个 char∗ 参数”,%d 表示“期望一个 int 参数”。但是,编译器通常无法确保调用中确实提供了预期的参数,或者参数属于预期的类型。例如:
#include <cstdio>
int main()
{
std::printf("My name is %s %s\n",2);
}
这不是有效的代码,但大多数编译器不会捕获此错误。最多会产生一些奇怪的输出(试试看!)。
显然,如果参数未声明,则编译器没有执行标准类型检查和类型转换所需的信息。在这种情况下,char 或 short 被传递为 int,而 float 被传递为 double。这不一定是程序员所期望的。
一个设计良好的程序最多需要几个未完全指定参数类型的函数。在大多数情况下,当人们考虑不指定参数类型时,可以使用重载函数、使用默认参数的函数、采用initializer_list 参数的函数和可变参数模板来处理类型检查。只有当参数数量和参数类型都不同并且可变参数模板解决方案被认为不可取时,才需要使用省略参数模式。
省略号的最常见用途是指定在 C++ 提供替代方案之前定义的 C 库函数的接口:
int fprintf(FILE∗, const char∗ ...); // from <cstdio>
int execl(const char∗...); //from UNIX header
在 <cstdarg> 中可以找到一组用于访问此类函数中未指定参数的标准宏。考虑编写一个错误处理函数,该函数接受一个整数参数,表示错误的严重程度,后跟任意数量的字符串。这个思想是通过将每个单词作为单独的 C 风格字符串参数传递来编写错误消息。字符串参数列表应以空指针结尾:
extern void error(int ...);
extern char∗ itoa(int, char[]); // int to alpha
int main(int argc, char∗ argv[])
{
switch (argc) {
case 1:
error(0,argv[0],nullptr);
break;
case 2:
error(0,argv[0],argv[1],nullptr);
break;
default:
char buffer[8];
error(1,argv[0],"with",itoa(argc−1,buffer),"arguments",nullptr);
}
// ...
}
函数 itoa() 返回表示其 int 参数的 C 风格字符串。它在 C 中很流行,但不是 C 标准的一部分。
我总是传递 argv[0] 因为按照惯例这是程序的名称。
请注意,使用整数 0 作为终止符是不可移植的:在某些实现中,整数 0 和空指针没有相同的表示(§6.2.8)。这说明了一旦使用省略号抑制类型检查,程序员将面临微妙和额外的工作。
error() 函数可以这样定义:
#include <cstdarg>
void error(int severity ...) // ‘‘severity’’ 后面跟一个以0结尾的char*列表
{
va_list ap;
va_star t(ap,severity); // arg startup
for (;;) {
char∗ p = va_arg(ap,char∗);
if (p == nullptr) break;
cerr << p << ' ';
}
va_end(ap); //arg cleanup
cerr << '\n';
if (severity) exit(severity);
}
首先,通过调用 va_star t() 定义并初始化一个 va_list。宏 va_star t 将 va_list 的名称和最后一个形式参数的名称作为参数。宏 va_arg() 用于按顺序选择未命名的参数。在每次调用中,程序员必须提供一个类型;va_arg() 假定已传递该类型的实际参数,但通常无法确保这一点。在从使用 va_star t() 的函数返回之前,必须调用 va_end()。原因是 va_star t() 可能会以无法成功返回的方式修改堆栈;va_end() 会撤消任何此类修改。
或者,可以使用标准库的initializer_list 来定义error():
void error(int severity, initializ er_list<string> err)
{
for (auto& s : err)
cerr << s << ' ';
cerr << '\n';
if (severity) exit(severity);
}
然后必须使用列表符号来调用它。例如:
switch (argc) {
case 1:
error(0,{argv[0]});
break;
case 2:
error(0,{argv[0],argv[1]});
break;
default:
error(1,{argv[0],"with",to_string(argc−1),"arguments"});
}
标准库 (§36.3.5) 提供了 int 到字符串的转换函数 to_string()。
如果我不必模仿 C 风格,我会通过将容器作为单个参数传递来进一步简化代码:
void error(int severity, const vector<string>& err) // almost as before
{
for (auto& s : err)
cerr << s << ' ';
cerr << '\n';
if (severity) exit(severity);
}
vector<string> arguments(int argc, char∗ argv[]) // package arguments
{
vector<string> res;
for (int i = 0; i!=argc; ++i)
res.push_back(argv[i]);
return res
}
int main(int argc, char∗ argv[])
{
auto args = arguments(argc,argv);
error((args.siz e()<2)?0:1,args);
// ...
}
辅助函数arguments()很简单,main()和error()也很简单。main()和error()之间的接口更通用,因为它现在传递所有参数。这将允许以后对error()进行改进。使用vector<string>比使用未指定数量的参数更不容易出错。
12.2.5 默认参数
通用函数通常需要比处理简单情况所需的更多参数。特别是,构造对象的函数(§16.2.5)通常提供多种灵活性选项。考虑§3.2.1.1 中的complex类:
class complex {
double re, im;
public:
complex(double r, double i) :re{r}, im{i} {} // 从2个标量构造复数
complex(double r) :re{r}, im{0} {} //从1个标量构造复数
complex() :re{0}, im{0} {}
// default complex: {0,0}
// ...
};
complex 构造函数的操作非常简单,但从逻辑上讲,让三个函数(这里是构造函数)执行基本相同的任务有些奇怪。此外,对于许多类来说,构造函数执行的工作更多,重复性很常见。我们可以通过将其中一个构造函数视为“真正的构造函数”并转交到该构造函数来处理重复性(§17.4.3):
complex(double r, double i) :re{r}, im{i} {} //从2个标量构造复数
complex(double r) :complex{2,0} {} //从1个标量构造复数
complex() :complex{0,0} {} // default complex: {0,0}
假设我们想在 complex 中添加一些调试、跟踪或统计信息收集代码;现在我们有一个地方可以这样做。但是,这可以进一步简化:
complex(double r ={}, double i ={}) :re{r}, im{i} {} //从2个标量构造复数
这清楚地表明,如果用户提供的参数少于所需的两个,则使用默认值。现在,使用单个构造函数加上一些简写符号的意图已经很明确了。
默认参数的类型在函数声明时进行检查,并在调用时进行估算。例如:
class X {
public:
static int def_arg;
void f(int =def_arg);
// ...
};
int X::def_arg = 7;
void g(X& a)
{
a.f(); // maybe f(7)
a.def_arg = 9;
a.f(); // f(9)
}
通常最好避免使用可以改变值的默认参数,因为它们会引入微妙的上下文依赖关系。
只能为尾随(trailing)参数提供默认参数。例如:
int f(int, int =0, char∗ =nullptr); // OK
int g(int =0, int =0, char∗); //error
int h(int =0, int, char∗ =nullptr); // error
请注意,∗ 和 = 之间的空格非常重要(∗= 是赋值运算符;§10.3):
int nasty(char∗=nullptr); // 语法错误
默认参数不能在同一作用域中的后续声明中重复或更改。例如:
void f(int x = 7);
void f(int = 7); // 错:不能重复默认参数
void f(int = 8); // 错: 不同的默认参数
void g()
{
void f(int x = 9); // OK:声明隐藏了外部的默认参数
// ...
}
在嵌套作用域中声明一个名称,使得该名称隐藏外部作用域中同名的声明,这很容易出错。
12.3 重载函数(Overloaded Functions)
通常,给不同的函数赋予不同的名称是一个好主意,但是当不同的函数在概念上对不同类型的对象执行相同的任务时,赋予它们相同的名称会更方便。对不同类型的操作使用相同的名称称为重载。该技术已用于 C++ 中的基本操作。也就是说,加法只有一个名称 +,但它可以用于添加整数和浮点类型以及这些类型的组合的值。这个思想很容易扩展到程序员定义的函数。例如:
void print(int); // print an int
void print(const char∗); // print a C-style string
就编译器而言,同名函数唯一的共同点就是名称。据推测,这些函数在某种意义上是相似的,但语言不会限制或帮助程序员。因此,重载函数名主要是为了方便记号。这种方便对于具有常规名称(如 sqrt、print 和 open)的函数非常重要。当名称具有语义意义时,这种方便就变得至关重要。例如,在构造函数(§16.2.5,§17.1)和泛型编程(§4.5,第 32 章)的情况下,使用诸如 +、∗ 和 << 之类的运算符时就会发生这种情况。
模板提供了一种定义重载函数集的系统方法(§23.5)。
12.3.1 引用参数(Automatic Overload Resolution)
当调用函数 fct 时,编译器必须确定要调用哪个名为 fct 的函数。这是通过将实际参数的类型与作用域内所有名为 fct 的函数的参数类型进行比较来完成的。其思想是调用与参数最匹配的函数,如果没有函数最匹配,则给出编译时错误。例如:
void print(double);
void print(long);
void f()
{
print(1L); // print(long)
print(1.0); // print(double)
print(1); // 错, 歧义: print(long(1)) 还是print(double(1))?
}
为了近似地了解什么是合理的概念,我们尝试了一系列标准,顺序如下:
[1] 精确匹配;即不使用或仅使用简单转换进行匹配(例如,数组名称到指针、函数名称到函数指针以及 T 到 const T);
[2] 使用提升进行匹配;即整数提升(bool 到 int、char 到 int、short 到 int 以及它们的无符号对应项;§10.5.1)以及 float 到 double
[3] 使用标准转换进行匹配(例如,int 到 double、double 到 int、double 到 long double、Derived∗ 到 Base∗(§20.2)、T∗ 到 void∗(§7.2.1)、int 到 unsigned int(§10.5) );
[4] 使用用户定义的转换进行匹配(例如,double 到 complex<double>;§18.4);
[5] 在函数声明中使用省略号 ... 进行匹配(§12.2.4)。
如果在找到匹配项的最高级别找到两个匹配项,则调用将被拒绝,因为存在歧义。解析规则如此复杂,主要是为了考虑到内置数值类型的复杂 C 和 C++ 规则(§10.5)。例如:
void print(int);
void print(const char∗);
void print(double);
void print(long);
void print(char);
void h(char c, int i, short s, float f)
{
print(c); // 准确匹配: 调用print(char)
print(i); //准确匹配: 调用print(int)
print(s); // 整数提升: 调用print(int)
print(f); // float 到 double 的提升: print(double)
print('a'); //准确匹配: 调用print(char)
print(49); //准确匹配: 调用print(int)
print(0); //准确匹配: 调用print(int)
print("a"); //准确匹配: 调用print(const char*)
print(nullptr); // nullptr_t 至 const char* 提升: 调用print(cost char*)
}
print(0) 调用会调用 print(int),因为 0 是 int。print('a') 调用会调用 print(char),因为 'a' 是 char (§6.2.3.2)。区分转换和提升的原因是我们希望优先选择安全的提升,例如从 char 到 int,而不是不安全的转换,例如从 int 到 char。另请参阅 §12.3.5。
重载解析与所考虑函数的声明顺序无关。函数模板的处理方式是将重载解析规则应用于基于一组参数的特化结果(§23.5.3)。当使用 {} 列表时,重载有单独的规则(初始化列表优先;§12.2.3,§17.3.4.1)和右值引用模板参数(§23.5.2.1)。
重载依赖于一组相对复杂的规则,有时程序员会对调用哪个函数感到惊讶。那么,为什么要费心呢?考虑一下重载的替代方法。通常,我们需要对几种类型的对象执行类似的操作。如果不重载,我们必须定义几个具有不同名称的函数:
void print_int(int);
void print_char(char);
void print_string(const char∗); // C-style string
void g(int i, char c, const char∗ p, double d)
{
print_int(i); // OK
print_char(c); // OK
print_string(p); // OK
print_int(c); // OK? calls print_int(int(c)), prints a number
print_char(i); // OK? calls print_char(char(i)), narrowing
print_string(i); // error
print_int(d); // OK? calls print_int(int(d)), narrowing
}
与重载的 print() 相比,我们必须记住几个名称并记住正确使用它们。这可能很乏味,使进行泛型编程的尝试失败(§4.5),并且通常鼓励程序员专注于相对低级的类型问题。由于没有重载,所有标准转换都适用于这些函数的参数。它也可能导致错误。在前面的例子中,这意味着编译器只会捕获四个语义可疑的调用中的一个。特别是,两个调用依赖于容易出错的收缩(§2.2.2,§10.5)。因此,重载可以增加编译器拒绝不合适参数的可能性。
12.3.2 重载与返回值(Overloading and Return Type)
重载解析不考虑返回类型。原因是要使单个运算符(§18.2.1,§18.2.5)或函数调用的解析与上下文无关。考虑:
float sqrt(float);
double sqrt(double);
void f(double da, float fla)
{
float fl = sqrt(da); // call sqrt(double)
double d = sqrt(da); // call sqrt(double)
fl = sqr t(fla); // call sqrt(float)
d = sqr t(fla); // call sqrt(float)
}
如果考虑返回类型,就不再可能单独查看 sqrt() 的调用并确定调用了哪个函数。
12.3.3 重载与作用域(Overloading and Scope)
重载发生在重载集的成员之间。默认情况下,这意味着单个作用域的函数;在不同的非命名空间作用域中声明的函数不会重载。例如:
void f(int);
void g()
{
void f(double);
f(1); //call f(double)
}
显然,f(int) 应该是 f(1) 的最佳匹配,但只有 f(double) 在范围内。在这种情况下,可以添加或减去局部声明以获得所需的行为。一如既往,有意隐藏可能是一种有用的技巧,但无意隐藏则会带来意外。
基类和派生类提供不同的作用域,因此默认情况下不会发生基类函数和派生类函数之间的重载。例如:
struct Base {
void f(int);
};
struct Derived : Base {
void f(double);
};
void g(Derived& d)
{
d.f(1); // call Derived::f(double);
}
当需要跨类范围 (§20.3.5) 或命名空间范围 (§14.4.5) 进行重载时,可以使用 using 声明或 using 指令 (§14.2.2)。参数相关查找 (§14.2.4) 也可能导致跨命名空间的重载。
12.3.4 多参数解析(Resolution for Multiple Arguments)
当不同类型计算的效率或精度差异很大时,我们可以使用重载解析规则来选择最合适的函数。例如:
int pow(int, int);
double pow(double , double);
complex pow(double , complex);
complex pow(complex, int);
complex pow(complex, complex);
void k(complex z)
{
int i = pow(2,2); // 调用pow(int,int)
double d = pow(2.0,2.0); // 调用pow(double,double)
complex z2 = pow(2,z); // 调用pow(double,complex)
complex z3 = pow(z,2); // 调用pow(complex,int)
complex z4 = pow(z,z); // 调用pow(complex,complex)
}
在选择具有两个或更多参数的重载函数的过程中,将使用 §12.3 中的规则为每个参数找到最佳匹配。调用与一个参数最匹配且与所有其他参数更匹配或相等的函数。如果不存在这样的函数,则该调用会因不明确而被拒绝。例如:
void g()
{
double d = pow(2.0,2); // 错: 是pow(int(2.0),2) 还是pow(2.0,double(2))?
}
该调用具有歧义,因为 2.0 是 pow(double,double) 第一个参数的最佳匹配,而 2 是 pow(int,int) 第二个参数的最佳匹配。
12.3.5 手动重载解析(Manual Overload Resolution)
声明函数的重载版本太少(或太多)都会导致歧义。例如:
void f1(char);
void f1(long);
void f2(char∗);
void f2(int∗);
void k(int i)
{
f1(i); // 歧义: f1(char) 或 f1(long)?
f2(0); //歧义: f2(char*) 或 f2(int*)?
}
如果可能,请将函数的重载版本集作为一个整体来考虑,并根据函数的语义查看它是否有意义。通常可以通过添加一个解决歧义的版本来解决问题。例如,添加
inline void f1(int n) { f1(long(n)); }
将解决与 f1(i) 类似的所有歧义,转而采用更大的 long int 类型。
还可以添加显式类型转换来解析特定调用。例如:
f2(static_cast<int∗>(0));
然而,这通常只是权宜之计。很快又会有类似的调用,我们必须处理。
有些 C++ 新手会对编译器报告的歧义错误感到恼火。经验丰富的程序员则认为这些错误信息是设计错误的实用提示。
12.4 前置和后置条件(Pre- and Postconditions)
每个函数对其参数都有一些期望。其中一些期望在参数类型中表达,但其他期望取决于传递的实际值以及参数值之间的关系。编译器和链接器可以确保参数的类型正确,但由程序员决定如何处理“坏”参数值。我们将函数调用时应该满足的逻辑标准称为前置条件,将函数返回时应该满足的逻辑标准称为后置条件。例如:
int area(int len, int wid)
/*
计算矩形面积
precondition: len 和wid 是正数
postcondition: 返回值是正数
postcondition: 返回值是具有长和宽的矩形面积*/
{
return len∗wid;
}
这里,前置条件和后置条件的语句比函数体要长。这似乎有些多余,但提供的信息对实现者、area() 的用户和测试人员都很有用。例如,我们了解到 0 和 −12 不被视为有效参数。此外,我们注意到,我们可以传递几个大值而不违反前置条件,但如果 len∗wid 溢出,则后置条件中的一个或两个都不满足。
我们应该如何处理调用area (numeric_limits<int>::max(),2)?
[1] 避免这种情况是调用者的任务吗?是的,但如果调用者不这样做怎么办?
[2] 避免这种情况是实现者的任务吗?如果是这样,如何处理错误?
这些问题有几种可能的答案。调用者很容易犯错误,无法建立前置条件。实施者也很难廉价、高效、完整地检查预置条件。我们希望依靠调用者来获得正确的预置条件,但我们需要一种方法来测试正确性。现在,只需注意一些预置条件和后置条件很容易检查(例如,len 为正,len∗wid 为正)。其他条件本质上是语义的,很难直接测试。例如,我们如何测试“返回值是边长为 len 和 wid 的矩形的面积”?这是一个语义约束,因为我们必须知道“矩形面积”的含义,而仅仅尝试以排除溢出的精度再次乘以 len 和 wid 可能会代价高昂。
写出 area() 的前置条件和后置条件似乎揭示了这个非常简单的函数的一个微妙问题。这并不罕见。写出前置条件和后置条件是一种很好的设计手段,可以提供良好的文档。记录和执行条件的机制在 §13.4 中讨论。
如果函数仅依赖于其参数,则其前置条件仅依赖于其参数。但是,我们必须小心依赖非局部值的函数(例如,依赖于其对象状态的成员函数)。在本质上,我们必须将每个非局部值视为函数的隐式参数。类似地,没有副作用的函数的后置条件只是表明值计算正确,但如果函数写入非局部对象,则必须考虑并记录其影响。
函数的编写者有几种选择,包括:
[1] 确保每个输入都有有效结果(这样我们就没有先决条件)。
[2] 假设预置条件成立(依赖调用者不犯错误)。
[3] 检查预置条件是否成立,如果不成立,则抛出异常。
[4] 检查预置条件是否成立,如果不成立,则终止程序。
如果后置条件失败,则存在未经检查的预置条件或编程错误。§13.4讨论了表示替代检查策略的方法。
12.5 指向函数的指针(Pointer to Function)
和(数据)对象一样,为函数体生成的代码放置在内存中的某个位置,因此它有一个地址。我们可以拥有指向函数的指针,就像我们可以拥有指向对象的指针一样。然而,由于各种原因——一些与机器架构有关,另一些与系统设计有关——指向函数的指针不允许修改代码。对函数只能做两件事:获取函数地址并调用它(直接调用或通过函数指针调用)。例如:
void error(string s) { /* ... */ }
void (∗efct)(string); // 指向一个取string参数而无返回值的函数的指针
void f()
{
efct = &error; // efct 指向error函数
efct("error"); // 通过efct 指针调用error函数
}
编译器将发现 efct 是一个指针,并调用指向的函数。也就是说,使用 ∗ 解引用指向函数的指针是可选的。同样,使用 & 获取函数地址也是可选的:
void (∗f1)(string) = &error; // OK: same as = error
void (∗f2)(string) = error; // OK: same as = &error
void g()
{
f1("Vasa"); //OK: same as (*f1)("Vasa")
(∗f1)("Mary Rose"); // OK: as f1("Mary Rose")
}
指向函数的指针的参数类型声明与函数本身一样。在指针赋值中,完整的函数类型必须完全匹配。例如:
void (∗pf)(string); // 指向 void(string)
void f1(string); // void(string)
int f2(string); // int(string)
void f3(int∗); //void(int*)
void f()
{
pf = &f1; // OK
pf = &f2; // 错: 错误的返回类型
pf = &f3; // 错: 错误的参数类型
pf("Hera"); // OK
pf(1); //错: 错误的参数类型
int i = pf("Zeus"); // 错: void 赋值给int
}
直接调用函数和通过指针调用函数的参数传递规则是相同的。
您可以将指向函数的指针转换为不同的指向函数的指针类型,但必须将结果指针转换回其原始类型,否则可能会发生奇怪的事情:
using P1 = int(∗)(int∗);
using P2 = void(∗)(void);
void f(P1 pf)
{
P2 pf2 = reinterpret_cast<P2>(pf)
pf2(); //可能有严重问题
P1 pf1 = reinterpret_cast<P1>(pf2); // 转回pf2
int x = 7;
int y = pf1(&x); // OK
// ...
}
我们需要最讨厌的强制类型转换 reinterpret_cast 来执行指向函数的指针类型的转换。原因是使用错误类型的指向函数的指针的结果非常不可预测且依赖于系统。例如,在上面的例子中,被调用的函数可能会写入其参数指向的对象,但调用 pf2() 并未提供任何参数!
函数指针提供了一种参数化算法的方法。由于 C 没有函数对象 (§3.4.3) 或 lambda 表达式 (§11.4),因此函数指针在 C 风格代码中被广泛用作函数参数。例如,我们可以将排序函数所需的比较操作作为函数指针提供:
using CFT = int(const void∗, const void∗);
void ssort(void∗ base, siz e_t n, size_t sz, CFT cmp)
/*
Sort the "n" elements of vector "base" into increasing order
using the comparison function pointed to by "cmp".
The elements are of size "sz".
Shell sort (Knuth, Vol3, pg84)
*/
{
for (int gap=n/2; 0<gap; gap/=2)
for (int i=gap; i!=n; i++)
for (int j=i−gap; 0<=j; j−=gap) {
char∗ b = static_cast<char∗>(base); // necessar y cast
char∗ pj = b+j∗sz; //&base[j]
char∗ pjg = b+(j+gap)∗sz; //&base[j+gap]
if (cmp(pjg,pj)<0) { // swap base[j] and base[j+gap]:
for (int k=0; k!=sz; k++) {
char temp = pj[k];
pj[k] = pjg[k];
pjg[k] = temp;
}
}
}
}
ssort() 例程不知道它排序的对象的类型,只知道元素的数量(数组大小)、每个元素的大小以及执行比较时要调用的函数。ssort() 的类型与标准 C 库排序例程 qsort() 的类型相同。实际程序使用 qsort()、C++ 标准库算法排序(§32.6)或专门的排序例程。这种代码风格在 C 中很常见,但它不是在 C++ 中表达此算法的最优雅方式(参见 §23.5,§25.3.4.1)。
此类排序函数可用于对如下表进行排序:
struct User {
const char∗ name;
const char∗ id;
int dept;
};
vector<User> heads = {
"Ritchie D.M.", "dmr", 11271,
"Sethi R.", "ravi", 11272,
"Szymanski T.G.", "tgs", 11273,
"Schr yer N.L.", "nls", 11274,
"Schr yer N.L.", "nls", 11275,
"Kernighan B.W.", "bwk", 11276
};
void print_id(vector<User>& v)
{
for (auto& x : v)
cout << x.name << '\t' << x.id << '\t' << x.dept << '\n';
}
为了能够排序,我们必须首先定义适当的比较函数。如果比较函数的第一个参数小于第二个参数,则必须返回负值;如果参数相等,则必须返回零;否则,必须返回正数:
int cmp1(const void∗ p, const void∗ q) // Compare name strings
{
return strcmp(static_cast<const User∗>(p)−>name,static_cast<const User∗>(q)−>name);
}
int cmp2(const void∗ p, const void∗ q) // Compare dept numbers
{
return static_cast<const User∗>(p)−>dept − static_cast<const User∗>(q)−>dept;
}
当函数指针被赋值或初始化时,参数或返回类型没有隐式转换。这意味着你无法通过以下方式避免丑陋且容易出错的强制类型转换:
int cmp3(const User∗ p, const User∗ q) // Compare ids
{
return strcmp(p−>id,q−>id);
}
原因是接受 cmp3 作为 ssort() 的参数会违反 cmp3 将使用 const User∗ 类型参数调用的保证(另请参阅§15.2.6)。
该程序排序并打印:
int main()
{
cout << "Heads in alphabetical order:\n";
ssort(heads,6,sizeof(User),cmp1);
print_id(heads);
cout << '\n';
cout << "Heads in order of department number:\n";
ssort(heads,6,sizeof(User),cmp2);
print_id(heads);
}
为了进行比较,我们可以等效地写出:
int main()
{
cout << "Heads in alphabetical order:\n";
sort(heads.begin(), head.end(),
[](const User& x, const User& y) { return x.name<y.name; });
print_id(heads);
cout << '\n';
cout << "Heads in order of department number:\n";
sort(heads.begin(), head.end(),
[](const User& x, const User& y) { return x.dept<y.dept; });
print_id(heads);
}
无需提及大小,也无需任何辅助函数。如果显式使用 begin() 和 end() 令人厌烦,可以使用采用容器的 sort() 版本(§14.4.5)来消除它:
sort(heads,[](const User& x, const User& y) { return x.name<y.name; });
您可以通过分配或初始化指向函数的指针来获取重载函数的地址。在这种情况下,目标的类型用于从重载函数集中进行选择。例如:
void f(int);
int f(char);
void (∗pf1)(int) = &f; // void f(int)
int (∗pf2)(char) = &f; // int f(char)
void (∗pf3)(char) = &f; // error : no void f(char)
也可以获取成员函数的地址(§20.6),但指向成员函数的指针与指向(非成员)函数的指针有很大不同。
指向 noexcept 函数的指针可以声明为 noexcept。例如:
void f(int) noexcept;
void g(int);
void (∗p1)(int) = f; // OK: 但我们丢掉了有用信息
void (∗p2)(int) noexcept = f; // OK:我们保留了noexcept 信息
void (∗p3)(int) noexcept = g; // 错: 我们不知g不会抛出异常
指向函数的指针必须反映函数的链接(§15.2.6)。类型别名中不得出现链接规范或 noexcept:
using Pc = extern "C" void(int); // 错 : 在别名中出现了连链规范
using Pn = void(int) noexcept; // 错: 别名中有noexcept
12.6 宏(宏指令)(大指令)(Macros——macroinstruction)
(译注:“macro”全称是“macro-instruction”,由前缀“macro-”(词义为“大”)+“instruction”(词义为“指令”)构成。该词创造于1959年,指的是“一组压缩成更简单形式并显示为单个指令的编程指令”。当然,在高级语言中,这里的“指令”并不是机器指令,而是编程的语句。因此,我们将其译为“宏语句”。)
宏指令在 C 中非常重要,但在 C++ 中的用途却少得多。关于宏指令的第一条规则是:除非迫不得已,否则不要使用它们。几乎每个宏指令都表明了编程语言、程序或程序员的缺陷。由于宏指令会在编译器正确看到程序文本之前重新排列程序文本,因此它们也是许多编程支持工具的主要问题。因此,当您使用宏时,您应该预料到调试器、交叉引用工具和分析器等工具的服务质量会很差。如果您必须使用宏指令,请仔细阅读您自己实现的 C++ 预处理器的参考手册,并尽量不要太聪明。另外,为了提醒读者,请遵循使用大量大写字母命名宏指令的惯例。宏指令的语法在 §iso.16.3 中介绍。
我建议仅将宏指令用于条件编译(§12.6.1)并且特别是包含保护(§15.3.3)。
一个简单的宏定义如下:
#define NAME rest of line
当 NAME被当作标记时,它将被行的其余部分替换。例如:
named = NAME
将展开为
named = rest of line
还可以定义宏指令来接受参数。例如:
#define MAC(x,y) argument1: x argument2: y
使用 MAC 时,必须提供两个参数字符串。它们将在 MAC() 展开时替换 x 和 y。例如:
expanded = MAC(foo bar, yuk yuk)
将展开为
expanded = argument1: foo bar argument2: yuk yuk
宏指令名不能重载,并且宏指令预处理器不能处理递归调用:
#define PRINT(a,b) cout<<(a)<<(b)
#define PRINT(a,b,c) cout<<(a)<<(b)<<(c) /* trouble?: redefines, does not overload */
#define FAC(n) (n>1)?n∗FAC(n−1):1 /*trouble: recursive macro */
宏指令操作字符串,对 C++ 语法知之甚少,对 C++ 类型或作用域规则一无所知。编译器只能看到宏指令的展开形式,因此宏指令中的错误将在展开时报告,而不是在定义时报告。这会导致非常模糊的错误消息。
以下是一些合理的宏指令:
#define CASE break;case
#define FOREVER for(;;)
以下是一些完全不必要的宏指令:
#define PI 3.141593
#define BEGIN {
#define END }
以下是一些危险的宏指令:
#define SQUARE(a) a∗a
#define INCR_xx (xx)++
要了解它们为什么危险,请尝试扩展这一点:
int xx = 0; // global counter
void f(int xx)
{
int y = SQUARE(xx+2); // y=xx+2*xx+2; that is, y=xx+(2*xx)+2
INCR_xx; //increments argument xx (not the global xx)
}
如果必须使用宏指令,请在引用全局名称时使用范围解析运算符::(§6.3.4),并尽可能将宏指令参数名称括在括号中。例如:
#define MIN(a,b) (((a)<(b))?(a):(b))
这可以处理更简单的语法问题(通常由编译器捕获),但不能处理副作用问题。例如:
int x = 1;
int y = 10;
int z = MIN(x++,y++); // x becomes 3; y becomes 11
如果您必须编写足够复杂以致需要注释的宏指令,则最好使用 /∗ ∗/ 注释,因为不知道 // 注释的旧 C 预处理器有时会将其用作 C++ 工具的一部分。例如:
#define M2(a) something(a) /*深思熟虑的 注释*/
使用宏指令,您可以设计自己的私有语言。即使您更喜欢这种“增强型语言”而不是普通的 C++,它对大多数 C++ 程序员来说也是难以理解的。此外,预处理器是一个非常简单的宏指令处理器。当您尝试做一些不平凡的事情时,您很可能会发现它要么不可能,要么不必要地难以做到。auto、constexpr、const、decltype、enum、inline、lambda 表达式、命名空间和模板机制可以用作许多传统预处理器构造用法的更好替代方案。例如:
const int answer = 42;
template<class T>
inline const T& min(const T& a, const T& b)
{
return (a<b)?a:b;
}
编写宏指令时,需要为某个东西起一个新名字是很常见的。可以使用 ## 宏指令运算符连接两个字符串来创建一个字符串。例如:
#define NAME2(a,b) a##b
int NAME2(hack,cah)();
将产生
int hackcah();
替换字符串中参数名称前的单个 # 表示包含宏指令参数的字符串。例如:
#define printx(x) cout << #x " = " << x << '\n';
int a = 7;
string str = "asdf";
void f()
{
printx(a); // cout << "a" << " = " << a << '\n';
printx(str); // cout << "str" << " = " << str << '\n';
}
写成 #x " = " 而不是 #x << " = " 是一种晦涩难懂的“聪明代码”,而不是错误。相邻的字符串文字是连接的(§7.3.2)。
指令
#undef X
确保没有定义名为 X 的宏指令—— 无论指令之前是否有宏指令。这可以在一定程度上防止不需要的宏指令。但是,要知道 X 对一段代码的影响并不总是那么容易。
宏的参数列表(“替换列表”)可以为空:
#define EMPTY() std::cout<<"empty\n"
EMPTY(); // print "empty\n"
EMPTY; // 错 : 宏指令替换列表丢失
我很难想到使用空宏指令参数列表不容易出错或具有恶意。
宏指令甚至可以是可变的。例如:
#define err_print(...) fprintf(stderr,"error: %s %d\n", __VA_ARGS__)
err_print("The answer",54);
省略号 (...) 表示 __VA_ARGS__ 表示实际以字符串形式传递的参数,因此输出为:
error: The answer 54
12.6.1 条件编译(Conditional Compilation)
宏的使用几乎是不可避免的。指令
#ifdef IDENTIFIER
如果定义了 IDENTIFIER,则该指令不执行任何操作,但如果未定义,则该指令会导致所有输入被忽略,直到看到 #endif 指令。例如:
int f(int a
#ifdef arg_two
,int b
#endif
);
除非已经 #defined 了名为 arg_two 的宏指令,否则将产生:
int f(int a
);
这个例子使那些认为程序员是行为理智的群体的人们感到困惑。
#ifdef 的大多数用法都不太奇怪,如果谨慎使用,#ifdef 及其补充 #ifndef 几乎不会造成任何危害。另请参阅 §15.3.3。
控制 #ifdef 的宏指令名应谨慎选择,以免与普通标识符冲突。例如:
struct Call_info {
Node∗ arg_one;
Node∗ arg_two;
// ...
};
如果有人写出下面这段看似无害的源文本,则会引起一些混淆:
#define arg_two x
不幸的是,常见的和不可避免的标题包含许多危险和不必要的宏。
12.6.2 预定义宏指令(Predefined Macros)
编译器预定义了几个宏指令(§iso.16.8,§iso.8.4.1):
• __cplusplus:在 C++ 编译器中定义(而不是在 C 编译器中)。在 C++11 程序中,其值为 201103L;以前的 C++ 标准具有较低的值。
• __DATE__:日期格式为“yyyy:mm:dd”。
• __TIME__:时间格式为“hh:mm:ss”。
• __FILE__:当前源文件的名称。
• __LINE__:当前源文件中的源行号。
• __FUNC__:实现定义的 C 风格字符串,用于命名当前函数。
• __STDC_HOSTED__:如果实现是托管的,则为 1(§6.1.1);否则为 0。
此外,实现有条件地定义了一些宏指令:
• __STDC__:在 C 编译中定义(而不是在 C++ 编译中)。
• __STDC_MB_MIGHT_NEQ_WC__:如果在 wchar_t 的编码中,基本字符集(§6.1)的成员可能具有与其作为普通字符文字的值不同的代码值,则为 1。
• __STDCPP_STRICT_POINTER_SAFETY__:如果实现具有严格的指针安全性(§34.5),则为 1;否则未定义。
• __STDCPP_THREADS__:如果程序可以有多个执行线程,则为 1;否则未定义。
例如:
cout << __FUNC__ << "() in file " << __FILE__ << " on line " << __LINE__ << "\n";
此外,大多数 C++ 实现允许用户在命令行或其他形式的编译时环境中定义任意宏指令。例如,除非编译是在(某些特定于实现的)“调试模式”下完成并由assert() 宏指令(§13.4)使用,否则会定义 NDEBUG。这可能很有用,但它确实意味着您不能仅通过阅读程序的源文本来确定程序的含义。
12.6.3 Pragma指令
实现通常提供与标准不同或超出标准的功能。显然,标准无法指定如何提供此类功能,但有一种标准语法是一行以预处理器指令 #pragma 为前缀的标记。例如:
#pragma foo bar 666 foobar
如果可能的话,最好避免使用 #pragma。
12.7 建议
[1] 将有意义的操作“打包”为精心命名的函数;§12.1。
[2] 函数应执行单个逻辑操作;§12.1。
[3] 保持函数简短;§12.1。
[4] 不要返回指向局部变量的指针或引用;§12.1.4。
[5] 如果函数可能需要在编译时进行求值,则将其声明为 constexpr;§12.1.6。
[6] 如果函数无法返回,则将其标记为 [[noreturn]];§12.1.7。
[7] 对小对象使用传递值;§12.2.1。
[8] 使用传递 const 引用来传递不需要修改的大值;§12.2.1。
[9] 将结果作为返回值返回,而不是通过参数修改对象;§12.2.1。
[10] 使用右值引用实现移动和转交;§12.2.1。
[11] 如果“无对象”是有效替代方案,则传递一个指针(并用 nullptr 表示“无对象”);
§12.2.1。
[12] 仅在必要时使用传递非 const 引用;§12.2.1。
[13] 广泛且一致地使用 const;§12.2.1。
[14] 假设 char∗ 或 const char∗ 参数指向 C 风格字符串;§12.2.2。
[15] 避免将数组作为指针传递;§12.2.2。
[16] 将长度未知的同类列表作为 initializer_list<T>(或其他容器)传递;§12.2.3。
[17] 避免未指定数量的参数(...);§12.2.4。
[18] 当函数在不同类型上执行概念上相同的任务时,使用重载;§12.3。
[19] 在整数上重载时,提供函数以消除常见的歧义;§12.3.5。
[20] 为函数指定预置条件和后置条件;§12.4。
[21] 优先使用函数对象(包括 lambda)和虚函数,而不是指向函数的指针;§12.5。
[22] 避免使用宏指令;§12.6。
[23] 如果必须使用宏指令,请使用带有大量大写字母的丑陋名称;§12.6。
内容来源:
<<The C++ Programming Language >> 第4版,作者 Bjarne Stroustrup