C++ Primer CH6 函数

函数(function)是具名的计算单元(named compute unit),在 C 和 C++ 中与子程序(subroutine)是不加区别的,指的就是执行特定任务的程序指令,它们被打包成一个单元。函数可以用在此任务需要执行的任何地方。

函数可以定义在程序中或者分离定义在库(library)中(定义在库中的代码可以被多个程序复用)。在不同的编程语言中,它具有不同的名字 —— 过程(procedure)、函数(function)、子程序(routine)、方法(method)、子程序(subprogram),以及更加通用的术语调用单元(callable unit)。

函数可以在程序的一次执行中从多个不同的地方以及多个不同的时间被调用(call),当计算完成时又返回到调用的下一条指令。Maurice Wilkes 等人称之为闭合子程序(closed subroutine)以区别于宏(macro)或者叫开放子程序(open subroutine),它们之间的区别在于函数有一个单独的作用域,调用时将实参进行求值然后初始化这个独立计算区域中的形参。而宏则是执行文本替换,实际上不存在调用的概念,将实参表达式替换每一个对应的形参,因而也会求值多次。只有闭合子程序才能支持递归调用。

函数的好处在于提高代码的密度(code density),复用代码可以有效的降低开发和维护大程序的成本,同时提高代码的质量和可靠性。面向对象编程(object-oriented programming)是建立在将函数与数据拼合在一起的原则上的。

从编译器的角度来看,程序就是一系列的函数的调用。

函数有两种功用,其一是数学函数(mathematical functions)的概念,就是纯计算,计算的结果完全由输入的参数决定,如:计算对数。其二是调用产生副作用(side effect)如:修改数据结构或者读写 I/O 设备,创建文件等。相同的参数在不同时期的调用结果很可能是不一样的,具体则依赖于当时的程序状态。

函数可以递归的调用的特性,使得可以直接将数学归纳(mathematical induction)和分治算法(divide and conquer algorithms)实现为程序代码。

6.1 函数基础

函数由返回类型、名字和形参(parameters)列表以及函数体构成。参数列表可能有一个或多个参数,也可能没有参数,参数之间用逗号分隔。函数的指令放在一个语句块中,被称为函数体(function body)。

执行函数用调用操作符(call operator),调用操作符是一对括号,调用操作符以函数或者函数指针为操作数,在括号则指定逗号分隔的实参(arguments),实参被用于初始化形参,初始化的过程与变量的初始化是一样的。调用表达式的类型是返回类型。

函数调用时将形参初始化为实参,并且将控制权转移到被调用的函数,此时调用函数(calling function)的执行被暂停,被调用函数(called function)开始执行。被调用函数执行的第一步是隐式定义并初始化参数。然后执行函数体。最后当函数遇到 return 语句或者执行到函数体尾部时自动返回控制权到调用函数,并且返回 return 语句中的值,这个值被用于初始化调用表达式的结果。

6.1.1 形参和实参

实参用于初始化形参,C++ 是按照参数顺序进行初始化的,并且不保证实参的求值顺序的。传入的参数必须与形参的类型匹配,并且个数也是必须一致的,因而,所有形参都保证一定会初始化。与初始化一样,允许从参数到形参的转换,只要这种转换是合法的。

C++ 中可以用一对空括号表示来表示没有参数,C 中早年必须在括号中写 void 来表示没有参数。如

void f() {/*...*/}
void f(void) {/*...*/}

形参列表就是一系列用逗号分隔的变量声明,变量声明之间是独立的,因而,即便是类型相同的参数也需要重复书写类型名。没有形参的名字是一样的,函数体内的顶层作用域中的本地变量也不会与形参同名。这与同一作用域中不允许同名变量是一样的。

偶尔函数的参数不使用,这种参数可以不命名,即便此参数不命名,当调用时还是必须传入对应的参数。

6.1.2 返回类型

返回类型可以是除数组类型和函数类型外的任何类型,可以是 void 类型表示没有没有返回值。虽然不能返回数组和函数,但是可以返回数组的指针和引用,以及函数指针。

6.1.3 本地变量

C++ 中的名字有作用域和生命周期(lifetimes)。作用域是程序文本中哪些位置可以看到此名字。生命周期是对象在程序运行期间存活的时期。函数体是一个语句块,因而生成一个新的作用域。形参和在此作用域中定义的变量被称为本地变量(local variables),本地变量会隐藏外部定义的变量。定义在任何函数外的变量在程序的整个执行过程中都存在,这种变量当程序启动时创建,直到程序中止时销毁。

函数中有一种本地变量是自动对象(automatic objects),这种变量当函数控制通过此变量定义时创建,当离开定义变量的语句块时销毁。当离开语句块时,其中定义的自动对象值是未定义的。参数是自动对象,当函数开始时将分配内存给参数,当函数结束时销毁。自动对象中的函数参数被初始化为函数实参,自动对象中的本地变量被初始化为定义时给出的初始值,或者被默认初始化,意味着未初始化的内置类型对象具有未定义值。

将本地变量定义为 static 将得到一个本地静态对象(local static object),本地静态对象将在第一次遇到对象的定义时初始化,当函数结束时对象不会被销毁,只有当程序结束时才会销毁。在多次调用函数之间,变量的值沿用上次调用时的最终值。当本地静态对象没有显式初始化时,将执行值初始化,意味着内置类型的本地静态变量将被初始化为 0。

6.1.4 函数声明

函数在使用前必须先声明,这样函数才会可见。函数与变量一样只能定义一次,但是可以声明多次。一个函数可以在没有定义的情况下声明,但想要调用此函数必须得有定义才行。函数声明与函数定义一样除了没有函数体之外。函数声明可以省略参数的名字只留下类型。函数声明分为三个部分:返回类型、函数名字和参数类型,这是调用函数需要知道的全部信息,这个三个元素合起来称为函数原型(function prototype)。

函数声明最好是放在头文件中,以保证所有引入的地方都保持一致,改变声明也仅需改变一个地方。实现函数的源文件也需要包含函数声明,这样可以校验定义和声明是否一致。

6.1.5 分离编译(Separate Compilation)

随着程序不断变大,以及为了支持库的开发。C++ 提供了类似于 C 语言一样的分离编译机制。将程序分为逻辑上的多个部分并且允许将这些部分放到不同的文件中去单独编译。编译出来的文件为对象文件(obj file),当最终需要可执行文件时再链接(link)成可执行文件。由于支持单独编译,因而,当只改动了一个文件时,可以对那一个文件进行编译然后链接在一起形成可执行文件。

6.2 参数传递

每次调用函数时都会创建形参并且用实参值进行初始化。对形参的初始化与对普通变量初始化是一样的。如果形参是引用,那么形参就会被绑定到实参上,这种方式的传递称为按引用传递(passed by reference),此函数调用称为按引用调用(called by reference),因而引用形参就是实参的别名。形参不是引用时就会将实参拷贝到形参中,此时形参和实参是相互独立的对象,这种方式的传递称为按值传递(passed by value),此函数称为按值调用(called by value)。

6.2.1 按值传递

当按值传递时,对形参做出的任何改变都不会影响到实参造成任何影响。以指针为形参需要记住一点就是指针间接访问对象,所以可以通过指针来改变对象值,在直观上与按值传递是不一致的。C 程序员通常用指针来访问调用函数中的对象,C++ 程序员则更多使用引用来访问。

6.2.2 按引用传递

按引用传递的一个作用就是使得函数可以改变其实参。而且很多对象拷贝很耗时,甚至有对象是不允许拷贝的。如:I/O 类型对象就不允许拷贝。C++ 的原则是对于不想改变值的对象使用 const 引用作为形参。

C++ 的函数只能返回一个值,当需要返回多个值时,引用使得可以有效返回多个结果。

6.2.3 const 形参和实参

与变量初始化一样。当用形参去初始化实参时,顶层 const 将被忽略,所以形参的顶层 const 不影响怎样传递实参,我们可以传递 const 和非 const 对象给有顶层 const 的形参。如果重载的函数只是形参的顶层 const 修饰不一样,可以用相同的参数去调用这两个函数,编译器没有足够的信息来区分这两个函数。这种情况下将被认为是重复定义。如:

void fcn(const int i) { /* ... */}
void fcn(int i) { /* ... */ } //error: 重复定义fcn

对于指针和引用的底层 const 修饰,与变量初始化一样,可以将非 const 对象用于初始化 const 对象,但不能执行相反的过程。对于非 const 引用,用于初始化的实参必须是相同类型的。所以这里有一个原则就是尽可能使用 const 引用,原因是非 const 引用导致函数只能接收类型精确匹配的左值对象,任何 const 对象、字面量或者需要转型的对象都被排除在外。除非是真正想要改变引用对应的实参值才会使用非 const 引用。

6.2.4 数组形参

数组是比较特殊的类型,原因在于数组不能够被复制,作为参数传递时数组会自动转换为指向头元素的指针。事实上即便是将形参形式写成数组的格式,接收的参数依然是指针。如:

void print(const int*)
void print(const int[]);
void print(const int[10]);

以上三个函数是完全一样的,参数都是 const int*,注意最后一个带了函数的大小,但是这个值会被编译器完全忽略掉。事实上,如果给上面几个函数进行定义,编译器会认为是重复定义。而且用 sizeof 对这几个参数进行求大小得到的结果是指针的大小而不是数组的大小。程序员必须自己保证传入的数组不会越界,这需要接口实现者和调用者协调保证,语言本身不会提供任何保护机制,如果真的发生了数组越界结果将是未定义的。因而,这种函数常常会有第二参数来传入数组的长度。

有三种方式来定义数组的边界:其一是设置一个结束的标记,如:C 风格字符串末尾的空字符。其二是传入头指针和尾后指针。其三是 C 语言和旧式 C++ 程序最常用的方式:传入额外的 size 参数。

与引用参数一样,除非是希望改写数组中的元素,应该将参数定义为指向 const 对象的指针。

除了以上传入数组首元素指针的形式,可以定义数组引用的形参。此时,数组的大小将称为类型的一部分,因而函数内可以依赖于此大小。然而由于函数接口定死了数组大小也限制了数组的运用范围。如:

void print(int (&arr)[10]);

以上函数必须将 & 号放在括号内部。

在第二章中介绍过多维数组,如果以多维数组作为参数传给函数,那么其实传的是指向二级数组的指针,并且子数组的长度是类型的一部分,因而必须指定其长度。如:

void print(int(*matrix)[10], int rowSize) { /*...*/ }

以上部分中 * 号必须放在括号内部,表示指针的优先级高于数组。并且与以下定义完全一样:

void print(int matrix[][10], int rowSize) { /*...*/ }

数组形式的参数与指针形式的参数是完全一致的。

6.2.5 main:处理命令行参数

C++ 程序允许从命令行传递选项到程序中去。方式是定义 main 函数是指定两个额外的参数。以下为其形式:

int main(int argc, char *argv[]) { /*...*/ }

其中第一个参数是选项的个数,第二个参数是指针数组,其中每个指针指向一个表示选项的 C 风格字符串。以下函数定义是完全一样的,将 argv 定义指向 char* 的指针。

int main(int argc, char **argv) { /*...*/ }

main 函数中 argv 指向的第一个元素是函数的名字或者空字符串,真正的选项是从 1 号位开始的。尾后元素被保证是空指针。如:

//prog -d -o ofile data0
argv[0] = "prog";
argv[1] = "-d";
argv[2] = "-o";
argv[3] = "ofile";
argv[4] = "data0";
argv[5] = 0;

6.2.6 不定形参

当不确定参数有多少个以及这些参数是什么类型时,需要用到不定形参。其实除了参数不一样外,行为都是一样的,重载多个函数完全不现实。C++ 中逐渐遗弃了 C 风格的省略号形参(Ellipsis Parameters),转而使用两种类型校验更强的方式。如果参数的类型是一样的,那么可以使用类 initializer_list ,如果类型都不一样那么使用可变参数模板(variadic template)。而省略号形式的不定形参被建议仅用于提供给 C 函数的接口。

initializer_list 是一个类模板,表示某种类型的数组,被定义在头文件 initializer_list 头文件中。以下简单列举此类对象可以执行的操作:

  • initializer_list<T> lst; 包含元素类型 T 的空列表;
  • initializer_list<T> lst{a,b,c...}; 将初始化列表中的值拷贝到列表中,最终列表中的值是 const 的;
  • lst2(lst) lst2 = lst 这种形式的初始化或赋值不会拷贝元素,而是共享相同的元素;
  • lst.size() 返回列表的元素个数;
  • lst.begin() lst.end() 返回头元素和尾后元素的迭代器;

需要记住的是 initializer_list 中的元素总是 const 的,不能改变其中元素的值。当传递给函数 initializer_list 参数时需要将序列放在大括弧中。如:

void error_msg(initializer_list<string> il);
error_msg({"functionX", "okay", "expected", "actual"});

省略号形如:void foo(parm_list, ...); ,其中省略号只能出现在参数列表的末尾。其中 parm_list后的逗号可以省略,但最好不要这样做以免引起歧义。有几个函数来帮助访问省略号形式的可变参数(variadic arguments)。

  • va_start 使得可以开始访问可变参数;
  • va_arg 访问下一个可变参数;
  • va_list 保存供 va_start va_arg va_end 访问的信息,必须首先调用;
  • va_end 结束访问可变参数;
#include <iostream>
#include <cstdarg>

//其 fmt 后省略了逗号
void simple_printf(const char* fmt...)
{
    va_list args;
    va_start(args, fmt);

    while (*fmt != '\0') {
        if (*fmt == 'd') {
            int i = va_arg(args, int);
            std::cout << i << '\n';
        } else if (*fmt == 'c') {
            // note automatic conversion to integral type
            int c = va_arg(args, int);
            std::cout << static_cast<char>(c) << '\n';
        } else if (*fmt == 'f') {
            double d = va_arg(args, double);
            std::cout << d << '\n';
        }
        ++fmt;
    }

    va_end(args);
}

int main()
{
    simple_printf("dcff", 3, 'a', 1.999, 42.5); 
}

在 C++ 中省略号形式的参数中如果有类类型,很可能不能正确的进行拷贝。并且省略号形式的参数是不做类型检查的。在 C 语言中要求在省略号前必须要有一个具名形参(named parameter),C++ 中如果没有具名参数虽然是合法的,但是无法访问可变参数。

6.3 返回类型和 return 语句

return 语句将终止函数的执行并返回控制权到函数调用点,将立即执行调用点之后的代码,这个地方叫做返回地址(return address),返回地址在执行调用时被保存在调用栈上。C++ 语言 return 语句返回一个返回值(return value)给调用者,而一些语言则没有提供返回值的语言特性,它们转而提供了出参数(output parameter),还有另外一些语言则默认函数的最后一条语句是函数的返回值,如:scala 。

当函数执行到最后一条语句时会自动返回。曾经有争议是否应当在函数的中间返回(称为早期退出 “early exit”),支持的观点有证据显示从中间返回使得程序员书写起来更不易出错,也更容易理解。而反对观点则认为中间返回将导致资源得不到有效释放,而从函数的底部退出就不会跳过释放的代码。C++ 通过在栈展开(stack unwinding)时由对象释放的析构函数自动调用进行资源释放,这种方式也被称为 RAII(resource acquisition is initialization)资源获取即初始化,很多人认为这是一个非常糟糕的术语用于描述对 C++ 来说几乎是最重要的概念,有人甚至认为应当用 DIRR (Destruction is Resource Relinquishment)析构即资源释放,或者叫 SBRM(Scope Bound Resource Management)局部绑定资源管理。

有两种形式进行函数返回:

return;
return expression;

6.3.1 函数没有返回值

如以下例子:

void swap(int &v1, int &v2)
{
    if (v1 == v2)
        return;
    int tmp = v2;
    v2 = v1;
    v1 = tmp;
}

没有返回值的函数的返回类型被定义为 void,其可以在函数中间用 return; 语句返回,这个 return 后是不带返回值的。如果返回了任何值便是编译错误,对于有返回值的函数也是一样的,如果返回的类型不一致并且无法转换便是编译错误。

如果没有返回语句,函数将在最后一条语句隐式返回。

void 返回类型函数可以用第二种形式的返回,但需要保证 expression 是对一个返回 void 类型的函数的调用,返回其它类型的表达式是编译时错误。

6.3.2 函数有返回值

当函数有返回值时只能用第二种形式的 return 语句,并且返回的值的类型必须与函数声明的返回值类型一致或者可以隐式转换过去,编译器会强制要求这一点。与 Java 不同的是声明为具有返回值的函数底部可以没有 return 语句,没有给出 return 语句是编译器不保证检查的错误,并且结果是未定义的,通常会导致程序异常退出。

返回值与变量和参数初始化方式是一样的。返回的值被用来初始化一个调用函数中的临时量(temporary),这个临时量就是被调用函数返回的结果。因此,如果返回不是引用,那么返回的值是什么对象都无关紧要,编译器会负责正确地进行拷贝。而如果返回的是引用,就必须遵守引用初始化的规则。

考虑以下函数:

const string& shorterString(const string &s1, const string &s2)
{
    return s1.size() <= s2.size() ? s1 : s2;
}

这里无论是调用时还是返回时都是 const string &,避免了复制对象的消耗,结果总是实参的一个别名。这里需要注意的一点是 s1 和 s2 不能是 C 风格字符串,原因在于编译器会自动将其转为 string 类型的临时量,而当函数调用完毕时这些临时量会被销毁,导致的结果就是返回的引用其实绑定到了一个不存在的对象上,其行为是未定义的。

记住:千万不要返回本地对象的指针和引用。当函数执行完时,为函数分配的任何本地变量内存都会被销毁。如果使用这样的值结果将是未定义的。

返回值如果是类类型,可以继续调用结果值的成员函数。如:shorterString(s1, s2).size()

C++ 的函数是否为左值取决于返回值是否为非 const 引用,返回非 const 引用则结果是左值,返回其它形式的值则是右值。返回引用的函数调用可以像别的左值一样操作,特别是可以给结果值赋予别的值。如:

char &get_val(string &str, string::size_type ix)
{
    return str[ix];
}
string s("a value");
get_val(s, 0) = 'A';

这也许会令人惊奇,但 C++ 中是允许这种操作的,而且是完全符合逻辑的。

在新标准中,函数可以返回置于括弧中的一列值,这个过程与列初始化是完全一样的。列表中的值被用于初始化函数的返回值,如果列表为空则返回值被值初始化。如果返回值是内置类型,那么括弧中最多只能有一个值,并且值不能执行精度变小的转换(narrowing conversion),如果返回的是类类型,则有类自己定义如何进行初始化。

从 main 函数中返回有一个例外:允许 main 函数不返回任何值,此时编译器会隐式返回 0 。main 的返回值被 Unix 认为是程序状态,任何非 0 值都被认为是发生了错误。

6.3.3 函数返回指向数组的指针

C++ 中不允许复制数组,函数因而不能声明为返回数组,但可以返回一个指向数组的指针或者数组引用。然而定义返回数组指针或者数组引用的函数有点难懂,因为引用和指针的优先级低于数组索引符。如:

int (*func(int i))[10]; //返回指向 int[10] 的指针
int (&func(int i))[10]; //返回 int[10] 的引用

为了改善这种难懂的声明式,C 语言提供了 typedef ,C++ 提供了新的 using 声明。如:

typedef int arrT[10];
using arrT = int[10];
arrT* func(int i);

新标准可以使用尾部返回类型(trailing return type)来简化函数声明,尾部返回类型的格式是原来的返回类型地方用 auto 占位,真正的类型放在参数列表后的 -> 之后,这种方式最常用的地方就是像返回数组指针或数组引用的复杂声明。如:

auto func(int i) -> int(*)[10];

另外一种做法是用 decltype 来推断返回类型,如:

int odd[] = {1,3,5,7,9};
int even[] = {0,2,4,6,8};
decltype(odd)* arrPtr(int i);

前面说过 decltype 不会将数组自动转型为指向头元素的指针,因而,返回的就是数组本身的类型,而且数组的长度是类型的一部分。所以,声明时需要加上 * 来说明返回的是数组的指针。

6.4 函数重载(Overloaded Functions)

C 语言是不支持函数重载的,即不允许具有同名函数。C++ 允许具有不同参数列表的函数有相同的名字,当它们出现在同一个作用域时就是重载(overloaded)。重载函数在不同的参数上执行类似的任务,编译器通过我们传递的实参来推断调用哪个函数。函数重载使得不再需要发明并记住更多名字,但只应该在重载函数执行的任务类似时才重载。需要记住的是 main 函数不能够被重载。

重载的函数必须在参数的个数和类型上有所区别,如果函数仅仅是返回值类型不一致将不符合重载的条件。

如果一个函数仅仅是某些参数的顶层 const 不一样也不符合重载条件。如:

Record lookup(Phone phone);
Record lookup(const Phone phone);

以上两个函数是一样的,因而,是重复定义。

另一方面可以定义底层 const 不一致的重载,即指针是否指向 const 对象,引用是否绑定到 const 对象。如:

Record lookup(Account& account);
Record lookup(const Account& account);
Record lookup(Account* account);
Record lookup(const Account* account);

由于不能从 const 对象转为非 const 对象,而可以从非 const 对象转为 const 对象。const 对象只能用于调用 const 版本的函数,而非 const 对象可以用于调用这两个版本的函数。如果同时存在两个版本,当使用非 const 对象进行调用时,编译器会调用非 const 版本的函数。

调用重载函数时编译器会进行函数匹配(function matching)或者叫重载解析(overload resolution),通过比较实参和形参来决定调用哪个重载的函数。在绝大部分时候函数匹配是简单而直接的,因为大部分重载函数的参数数量不同或者参数类型不相关。真正困难的地方在参数数量一样而且类型相关的时候。6.6 节将描述怎样进行不同重载函数的参数类型可以转换的函数匹配,这里给出三个函数匹配可能的结果:1. 当存在一个最优匹配时选用这个最优匹配;2. 当没有重载的函数可以匹配函数调用时,产生编译时错误;3. 当有多于一个重载匹配函数调用,并且没有一个是优于其它的,是模糊调用(ambiguous call),产生编译时错误;

函数重载只存在于同一个作用域内,不同的作用域不存在重载,如果在内嵌作用域中定义了相同的名字会隐藏外部作用域中的相同名字。重载不能跨作用域。这个道理可以运用到子类隐藏基类中的相同函数名字。如:

void print(const string&);
void fooBar(int ival)
{
    string s = read();
    void print(int); //将隐藏外部的 print 函数,不推荐在函数内声明函数
    print("Value: "); //编译时错误,此时外部函数已经被隐藏了
}

在 C++ 中名称查找发生在类型匹配之前,被隐藏的名字不在函数调用的考虑范围内。

6.5 C++ 函数特殊特性

C++ 有三种函数相关的特性,这些特性并不需要运用于所有函数,而仅当需要时使用。它们分别是默认实参(default argument)、内联函数和 constexpr 函数,以及在程序调试过程中常用的一些功能。

6.5.1 默认实参(Default Arguments)

默认实参用于当函数参数在绝大多数时候值都是某个特定值时,当调用函数时可以不提供这个参数,函数参数进行初始化时就使用声明时给出的默认参数,当调用者也可以明确给出此参数的值。默认实参在形参列表中以初始值的形式给出,可以给一个或多个形参给出默认实参,如果某个参数被赋予了默认值,其后的所有参数都必须给出默认值。如:

string screen(size_t ht = 24, size_t wid = 80, char backgrnd = '*');

调用函数时可以省略带有默认实参的参数,需要注意的是如果省略了某个参数,其后所有参数也必须省略。如果为某个已经有默认实参的参数提供值,其前面的所有参数都必须给出值,原因在于 C++ 的函数只支持按位置初始化参数,而 Python 可以按形参名字给出对应实参。如:

string window;
window = screen(); //screen(24, 80, '*');
window = screen(66); //screen(66, 80, '*');
window = screen(24, 80, '#'); //为覆盖最后的 backgrnd 参数,前面的参数必须给出

设计带有默认实参的函数的一个原则是将最不可能改变的参数放在最右边。

虽然函数可以声明多次,但对带有默认实参的函数有限制:同一作用域内的多次函数声明,只能提供一次默认值,所有的默认值将叠加起来形成最终的函数声明,即便是相同的多次给出默认值都是错误。如:

string screen(sz, sz, char = ' ');
string screen(sz, sz, char = ' '); //声明最后一个参数的默认值多次,即便是相同也是错误的
string screen(sz, sz, char = '*'); //改变更加是错误的
string screen(sz=24, sz=80, char); //增加是可以的,最终将是 string screen(sz=24, sz=80, char=' ');

通常是将默认实参放在函数声明中,虽然放在函数定义的参数列表中也是可以的,但并不常用,说到底其实提供默认值的时候看的依然是当时可见的函数声明,函数定义的头部也是一种函数声明。

用于初始化默认实参的值除了可以是常量表达式之外,还可以是全局的变量、函数返回值,但不能是本地变量。作为函数默认实参的表达式中的名字将在函数声明时进行名字解析,而求值发生在函数调用时。另外,如果是内嵌的相同函数声明,将隐藏外部作用的函数声明。具体查看:https://github.com/chuenlungwang/cppprimer-note/blob/master/code/default_arguments.cpp

6.5.2 内联函数和 constexpr 函数

内联函数是 C++ 为了改进 C 中类函数宏所作出的努力,内联函数具有闭合子程序的所有特性,然而编译器会将函数调用过程展开为执行代码。优雅地融合了函数和宏的特性。好处在于:更加容易阅读和理解程序意图、行为统一、易于修改和重用,展开执行代码将减轻了函数调用的额外工作:保存寄存器并恢复、赋值参数、程序跳转。由于内联函数将在每一处调用时进行展开,所以,要求要函数将是非常小的,不然生成的执行文件将很大,如果函数是递归的更是不应该内联。而且由于内联展开发生在编译期间,所以,应当将内联函数代码放在头文件中,才能在编译其它文件时进行展开,原因是编译器必须看到执行的代码才能展开。内联函数通常是给编译器的一种提示,编译器会根据实际情况内联或者忽略。如:

inline const string&
shorterString(const string &s1, const string &s2)
{
    return s1.size() <= s2.size() ? s1 : s2;
}

通常来说,内联函数都是非常小的,常常只有一个表达式。

constexpr 函数是通常是非常简单的函数,当接收常量表达式作为参数时,返回的值必定是常量值,其函数体所执行的操作亦必须是编译器可以在编译期间执行的操作。这样的情况下,constexpr 函数就可以作为常量表达式(constant expression)用于需要常量表达式的场景中。因而,constexpr 函数需要满足以下限制:返回类型和参数类型都必须是字面类型(literal type)(参考第二章内容),函数体内必须只包含一个 return 语句。如:

constexpr int new_sz() { return 42; }
constexpr int foo = new_sz();

编译可以验证 constexpr 函数返回的值是常量表达式,并且可以用于初始化 constexpr 变量,如果不符合限制,编译器会报错,并不允许编译通过。在符合条件的情况下编译器会将所有的 constexpr 函数调用都替换为求值结果。为了让编译器能够求值成功,编译器必须看到所有的 constexpr 的函数体代码,所以,隐式要求 constexpr 为内联的。

constexpr 函数可以接收字面类型的参数,并对其执行运算,但所有的运算都必须是编译器可以完成的,也就是说一定不可以包含内存分配、对象初始化、IO 等操作,即没有运行时操作。但可以包括类型别名和 using 声明,这些是不涉及运行时操作的。

接收参数的 constexpr 函数当用常量表达式参数进行调用时结果是常量表达式,当用运行时变量调用时结果不是常量表达式。语言是允许这样的操作的,仅当确实需要常量表达式时编译器才会要求 constexpr 函数返回常量表达式。如:

constexpr size_t scale(size_t cnt) { return new_sz() * cnt; }
int arr[scale(2)]; //常量表达式
int i = 2;
int a2[scale(i)]; //错误,scale(i)不是常量表达式

6.5.3 辅助 Debugging 的特性

C++ 沿用了 C 中使用 assert 宏来断言某些不可能的状态,当 assert 中表达式求值结果为 false 将导致程序打印错误信息并退出。使用 assert 宏的文件不能再定义名为 assert 的函数、变量,否则将会被认为是 assert 宏,因为,宏替换发生在编译器编译之前。assert 宏的行为只有在没有定义宏 NDEBUG 时才会执行,可以通过在编译时提供编译选项 -D NDEBUG 来禁用掉 assert 宏的执行。通常,我们在开发时定义 assert ,在生产上线时关闭 assert 。

C++ 编译器定义几个特殊的预处理变量用于辅助调试:

  • __func__ 当前所在函数名;
  • __FILE__ 当前所在文件名;
  • __LINE__ 当前行号;
  • __TIME__ 文件编译时的时间;
  • __DATE__ 文件编译时的日期;

6.6 函数调用匹配

函数匹配(function matching)发生在函数调用时,编译器依据函数调用的参数类型和个数选择哪个重载函数是最优的,通常这个过程是简单而直接的,仅当函数参数的个数一致而且类型又相关时会比较复杂。如:

void f(int);
void f(int, int);
void f(double, double=3.14)
f(5.6); //f(double);
f(5); //f(int);
f(5, 5);  //f(int, int);
f(5, 5.6);  //错误,调用模糊

以上究竟调用哪个函数是很难分辨的,甚至有函数调用是错误的,因为,编译器亦不知该调用哪一个函数。

编译器进行函数调用匹配分为三步:选择候选函数(candidate functions)和决定可行函数(viable functions),从可行函数中选择最优匹配。候选函数是与调用函数同名的可见重载函数集合。可行函数则是与调用匹配的函数,其参数数目一致并且类型要么精确匹配要么可以进行转换。如果没有可行函数则是调用不匹配错误,如果有多于一个可行函数,而没有一个是绝对优于另外一个的,则是调用模糊(ambiguous)错误。所谓优于意思是没有一个参数匹配是比别的可行函数差的,并且有至少一个参数匹配是优于别的可行函数的。所谓参数匹配的优只的是形参与实参之间的类型更加靠近。

f(5, 5.6); 的前一个参数可已经精确匹配 f(int, int); 而第二个参数需要转换。对于 f(double, double); 则第二个参数精确匹配,但第一个参数需要转换。这里有一个观点:int -> double 的转型并不优于 double -> int 转换更优。

通常不应该重载 f(double, double)f(int, int) ,这并不是好的设计。同时定义引用参数和非引用参数也是非常不好的设计,函数调用必定产生调用模糊错误。如:f(int)f(int&)

6.6.1 实参类型转换

编译器为了决定出最佳匹配,对以下转换进行了排序,从上到下依次变差:

  • 精确匹配:包括完全一致,由数组或函数转为对应的指针类型,顶层 const 的增加或去除;
  • const 转换:指向非 const 对象指针转为指向 const 对象指针,非 const 引用转为 const 引用(第四章有描述);
  • 整型提升;
  • 算数转换或指针转换(第四章有描述);
  • 类类型转换;

6.7 函数指针

函数指针就是指向函数的指针,函数指针的类型由函数的签名决定:返回值类型和参数列表类型,名字则被忽略。如:

bool lengthCompare(const string&, const string&);

的类型为

bool(const string&, const string&)

定义函数指针与定义数组指针类似,都需要用到括号来强制优先级。

bool (*pf)(const string&, const string&);

如果定义为

bool *pf(const string&, const string&);

其含义是声明一个函数,返回 bool 的指针。

当需要函数指针时,函数名会自动转为指针。如:

pf = lengthCompare;
pf = &lengthCompare; //与以上完全一致

可以使用函数指针进行函数调用,如:

bool b1 = pf("hello", "goodbye");
bool b2 = (*pf)("hello", "goodbye");
bool b3 = lengthCompare("hello", "goodbye"); //三个函数调用是完全一致的

函数指针之间不存在转换,不能令一个类型的函数指针指向别的不同类型的函数,即使是重载函数或者只有参数类型存在转换关系或者仅仅返回值类型不一样也是不可以的。不过,可以将 nullptr 或字面量 0 赋值给函数指针用于表示不指向任何函数。

对于用重载的函数名进行初始化,编译器会依据指针的类型识别是哪一个重载函数,只有完全精确匹配才是正确的函数。

C++ 的函数不能以函数作为参数,但可以将函数指针作为参数,这与数组是一样的。即便将形参写作函数形式,其实质也是一个函数指针。

void useBigger(const string &s1, const string &s2, bool pf(const string&, const string&));
void useBigger(const string &s1, const string &s2, bool (*pf)(const string&, const string&));
//以上两个是完全一致的

调用时将函数名作为实参,将自动转为函数指针。如:useBigger(s1, s2, lengthCompare);

使用类型别名和 decltype 可以简化函数指针的声明。如:

typedef bool Func(const string&, const string&);
typedef decltype(lengthCompare) Func2;  //声明 Func 和 Func2 为函数类型
typedef bool (*FuncP)(const string&, const string&);
typedef decltype(lengthCompare) *FuncP2; //声明 FuncP 和 FuncP2 为函数指针

需要注意的是 decltype 并不会将函数类型自动转为指针。按以上声明之后,可以将 Func 和 FuncP 作为函数参数。如:

void useBigger(const string&, const string&, Func);
void useBigger(const string&, const string&, FuncP);

C++ 函数不能返回函数,但可以返回函数指针,这与数组特性一致。并且必须书写为返回函数指针,写成返回函数并不会自动转为返回函数指针。函数返回函数指针的书写格式是难以理解的,如:

int (*f1(int))(int*, int);

表示一个函数 f1 返回 int (*)(int*, int) 函数指针。可以用别名的方式来简化返回类型,如:

using F = int(int*, int); //F是函数类型
using PF = int(*)(int*, int); //PF是指针类型
PF f1(int);
F f1(int); //错误
F *f1(int);

除此之外还可以使用尾置返回类型的方式。如:

auto f1(int) -> int(*)(int*, int);

还有就是直接使用 decltype 来推断返回类型。如:

string::size_type sumLength(const string&, const string&);
string::size_type largerLength(const string&, const string&);
decltype(sumLength) *getFcn(const string&);

这与前面的返回数组指针是一样的,需要了解的是当将 decltype 运用于函数时,返回的是函数类型,并且不会自动转为函数指针。

关键术语

  • 调用模糊(ambiguous call):一种编译期错误,当函数调用进行匹配时发现两个以上相同优良的匹配所产生的错误;
  • 自动对象(automatic objects):仅当函数执行时存在的对象,当程序执行到对象定义时创建,当离开其所在块时销毁;
  • 最佳匹配(best match):从一系列重载函数中选择一个函数,此函数与其它函数对比,至少有一个参数是优于其它函数的,并且没有一个参数是差于别的函数的;
  • 函数原型(function prototype):函数声明,包括函数名字、返回类型和参数类型。要想调用一个函数,必须在调用之前声明函数原型;
  • 隐藏名字(hidden names):在内嵌的作用域中声明的名字将隐藏之前在外部作用域中声明的名字;
  • 本地变量(local variables):在块中定义的变量;
  • 对象声明周期(object lifetime):每个对象都有自己的声明周期。自动对象从定义的位置创建,在退出所在块时被销毁。全局对象在函数执行即存在,一直存活到程序结束。本地 static 变量将在第一次执行定义时创建,并与全局变量一样一直存活到程序结束;
  • 递归循环(recursion looop):描述缺少终止条件的递归函数,如果调用这种函数将最终耗尽程序的调用栈;

Leave a Reply

Your email address will not be published. Required fields are marked *