C++ Primer 学习笔记 第六章 函数

函数是一个命名了的代码块,我们通过调用函数执行相应代码。函数可以有0个或多个参数,通常会产生一个结果。

一个典型的函数定义包括返回类型、函数名、由0个或多个形参组成的列表和函数体。形参以逗号,隔开,形参列表位于一对圆括号内。

我们通过调用运算符()来执行函数,它作用于一个表达式,该表达式是函数或指向函数的指针。圆括号内是一个用逗号隔开的实参列表,我们用实参初始化函数的形参。调用表达式的类型就是函数的返回类型。

函数的调用完成两个工作:一是用实参初始化函数对应的形参,二是将控制权转移给被调用函数。此时,主调函数的执行被中断,被调函数开始执行。

执行函数的第一步是隐式地定义并初始化它的形参。

遇到一条return语句时函数结束执行过程,完成两项工作,一是返回return语句中的值(如果有),二是将控制权转移回主调函数。函数的返回值用于初始化调用表达式的结果。

实参是形参的初始值。第一(二)个实参初始化第一(二)个形参。尽管形参和实参存在对应关系,但没有规定实参的求值顺序。编译器能以任意可行的顺序对实参求值。实参的类型必须与形参类型匹配(类型相同或实参类型能转化为形参类型)。实参数量也应该与形参数量一致。

void func(char* pc) { }

int main() {
	string s = "sss";
	func(s);    //错误,正如我们所知道的,string类型不能赋值给C风格字符串
}

函数的形参列表可以为空,但不能省略括号,定义不带形参的函数:

void f1() {}    //隐式定义空形参列表
void f1(void) {}    //显式定义空形参列表,这是为了与C语言兼容

形参列表中形参要用逗号隔开,每个形参都是一个含声明符的声明,即使形参的类型一样:

int f3(int v1, v2) {}    //错误
int f4(int v1, int v2) {}    //正确

任意两个形参不能同名,且函数中最外层作用域的局部变量也不能和形参同名。

形参名是可以省略的,但我们无法使用未命名的形参。偶尔我们在函数中不会用到个别形参,可以通过不命名来表示函数体内不会使用它:

int a(int) {}

函数的返回值不能是数组类型或函数类型,但可以是指向函数或数组的指针。特殊的返回类型void表示函数不返回任何值。

C++中名字有作用域,对象有生命周期。名字的作用域是程序文本的一部分,名字在这部分中可见。对象的生命周期是程序执行过程中对象存在的一段时间。

函数体是一个语句块,块构成新的作用域。形参和函数体内部定义的变量为局部变量,仅在函数的作用域内可见,同时局部变量还会隐藏在外层作用域中同名的声明。

在所有函数体之外定义的对象存在于程序的整个执行过程中。此类对象在程序启动时被创建,直到程序结束才会销毁。局部变量的生命周期依赖于定义的方式。

自动对象:只存在于块执行期间的对象。当函数的控制路径经过变量定义语句时创建该对象,当到达定义所在的块末尾时销毁它。当块的执行结束后,块中创建的自动对象的值就变成未定义的了。

形参是一种自动对象,函数开始时为形参申请存储空间,函数一旦终止,形参也就被销毁。

对于局部变量对应的自动对象来说,如定义时赋了初始值,就用这个初始值进行初始化,否则执行默认初始化,这意味着内置类型未初始化局部变量将产生未定义的值。

局部静态对象:在程序的执行路径第一次经过对象定义语句时初始化,直到程序终止才被销毁,在此期间即使对象所在的函数结束执行也不会对它有影响。以下是一个例子,统计函数它自己被调用了多少次:

#include <iostream>
using namespace std;

int func() {
    static int res = 0;
    return ++res;
}

int main() {
    for (int i = 0; i < 5; ++i) {
        cout << func() << endl;
    }
    return 0;
}

以上代码中程序流第一次经过res的定义之时被创建并初始化为0,每次调用将res加1并返回其值。除第一次每次执行func时,res的值都已存在并且等于函数上一次退出时的值。

如局部静态变量没有显式的初始值,它将执行值初始化,内置类型的局部静态变量初始化为0。

函数必须在使用之前声明,类似于变量,函数只能定义一次,但可以声明多次。函数声明与函数定义很类似,但它不包含函数体,用分号替代即可。因为函数声明不包含函数体,也就不用形参名,函数声明中常省略形参名,但写上形参名还是有好处的,它可以帮助使用者更好地理解函数的功能。

函数三要素:返回类型、函数名、形参类型,描述了函数的接口,有调用该函数所需全部信息。函数声明也称作函数原型。

与变量相似,函数也应该在源文件中定义、头文件中声明,这样可以保证同一函数的所有声明都一致,并且在修改函数接口时,只需改变头文件中的一条声明即可。

定义函数的源文件应该把含有函数声明的头文件包含进来,这样编译器会检查函数的定义与声明是否匹配。

分离式编译:我们可以把程序分割到几个文件中去,每个文件独立编译。

编译和链接多个源文件:如fact函数位于名为fact.cc的文件中,它的声明位于Chapter6.h的头文件中,显然fact.cc应该包含Chapter6.h头文件。另外main函数在factMain.cc的文件中。要想生成可执行文件,必须告诉编译器我们用到的代码在哪里,对以上几个文件来说,编译的过程如下:

$ CC factMain.cc fact.cc            # generates factMain.exe or a.out
$ CC factMain.cc fact.cc -o main    # generates main or main.exe

其中,CC是编译器名字,$是系统提示符,#后面是命令行下的注释。

如果我们修改了其中一个源文件,那么只需重新编译这个改动了的文件。大多数编译器提供了分离式编译每个文件的机制,这一过程通常会产生一个后缀名为.obj(Windows)或.o(UNIX)的文件,含义为该文件包含的是对象代码(object code)。

接下来编译器负责把对象文件链接在一起形成可执行文件,编译过程如下:

$ CC -c factMain.cc               # generates factMain.o
$ CC -c fact.cc                   # generates fact.o
$ CC factMain.o fact.o            # generates factMain.exe or a.out
$ CC factMain.o fact.o -o main    # generates main or main.exe

具体编译过程查看编译器用户手册。

形参初始化机理与变量初始化一样。

与其他变量一样,形参的类型决定了形参和实参的交互方式,如形参是引用类型,就会绑定到对应实参上,否则,将实参的值拷贝给形参。

形参是引用时,我们说它对应的参数被引用传递或者函数被传引用调用。引用形参是它实参的别名。

实参的值被拷贝给形参时,形参和实参是两个相互独立的对象,这样的实参被值传递或者函数被传值调用。

当初始化一个非引用类型变量时,初始值拷贝给变量,此时,对变量的改动不会影响初始值。传值函数的机理与此完全一样,对形参所做的操作不会影响实参。

指针形参:当执行指针拷贝操作时,拷贝的是指针的值,仍可以通过指针访问它所指的对象。指针当形参时机理与此完全一样:

void reset(*ip) {
    *ip = 0;    //改变了ip所指对象的值
    ip = 0;     //只改变了ip的局部拷贝,实参值未改变
}

C++中建议使用引用类型的形参来代替指针形参。因为当拷贝大的类类型对象或者容器对象比较低效,甚至有的类类型根本就不支持拷贝操作。当函数无需修改引用的形参的值时最好使用常量引用。

一个函数只能返回一个值,有时函数需要同时返回多个值,可以使用引用形参间接返回多个结果。

const形参的一种情况:

void fcn(const int i) {}
void fcn(int i) {}    //失败,重复定义了fcn

传参时如果是指针或引用传值,实参给形参赋值的规则与变量间的赋值规则相同:

void reset(int &a) {}
unsigned a = 2;
reset(a);    //错误,引用类型必须与被引用的变量类型相同
reset(42);    //错误,普通引用不能绑定在字面值上

把函数不会改变的形参最好定义为常量引用,否则会带给函数调用者一种误导:函数可以修改它实参的值。并且,使用非常量引用也会限制函数所能接受的实参类型,如const对象、字面值或需要类型转换的对象:

    int i = 8;
    long& l = i;    //错误,引用类型要与被绑定的类型相同
    const long& l = i;    //正确,i先转化为临时的const long对象,然后再让临时变量与l绑定

由于数组的两个特性:不允许拷贝数组和数组使用时通常转化为指针,所以当我们为函数传递一个数组时,实际上传递的是指向数组首元素的指针。

尽管不能以值传递的方式传递数组,但形参依然可以写成数组形式:

void func(const int*);
void func(const int[]);
void func(const int[10]);    //维度表示我们期望传进来的数组含10个元素,实际不一定

数组是以指针形式传递给函数的,因此函数并不知道数组的尺寸,可以通过以下三种方式管理指针形参:
1.使用标记指定数组长度。
数组中有结束标记,适用于有明显结束标记并且该标记不会与普通数据混淆的情况。典型是C风格字符串:

void print(const char *cp) {
    if (cp) {    //如果cp不是空指针
        while (*cp) {    //只要指针指向的字符不是空字符
            cout << *cp++;    //输出当前字符并将指针向尾部移动一个位置
        }
    }
}

原因为:

    char c = '\0';
    if (c) {
        cout << "空字符用于条件判断时为真。" << endl;
    }
    else {
        cout << "空字符用于条件判断时为假。" << endl;    //输出这句话
    }

2.使用标准库规范。
传递指向数组首元素和尾后元素的指针:

void print(const int *beg. const int *end) {
    while (beg != end) {
        cout << *beg++ << endl;    //输出当前字符并将指针向尾部移动一个位置
    }
}

3.显式传递一个表示数组大小的形参。

当形参为数组的引用时:

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

这种方法也限制了输入,只能输入大小为5的int型数组。

当将多维数组传递给函数时,真正传递的是指向数组首元素的指针,首元素本身就是一个数组,这个指针就是指向数组的指针。

多维数组第二维(以及后边的所有维度)大小不能省略。

main函数的形参列表:

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

第二个形参argv是一个数组,它的元素是指向C风格字符串的指针,第一个形参argc表示数组中字符串的数量。

加入main函数处于可执行文件prog内,我们可以向程序传递以下选项:

prog -d -o ofile data0

此时,main函数接收到的argc为5,argv的第一个元素指向程序的名字或者一个空字符串,接下来的元素依次传递命令行提供的实参,最后一个元素值保证为0(空指针),以上面命令行内容为例,argv包含以下C风格字符串:

argv[0] = "prog";    //或者argv[0]也可以指向一个空字符串
argv[1] = "-d";
argv[2] = "-o";
argv[3] = "ofile";
argv[4] = "data0";
argv[5] = 0;    //argv[argc]为空指针

使用argv中的实参时,要从argv[1]开始,argv[0]保存程序名,而非用户输入。

将int对象转化为string对象:

string s = to_string(3);

C++11新标准提供了两种方法来编写能处理不同数量实参的函数:
1.如果所有实参类型相同,可以传递一个名为initializer_list的标准库类型。
2.如果实参的类型不同,我们可以编写可变参数模板。

C++还提供一种特殊的形参类型(省略符),用它可以传递可变数量的实参,但这种功能一般只用于与C函数交互的接口程序。

当函数的实参数量未知,但是全部实参都是同一类型,可以使用initializer_list类型的形参,initializer_list是一种标准库类型,用于某种特定类型值的数组,定义在同名头文件中。

initializer_list提供的操作:

语法 含义
initializer_list<T> lst; 默认初始化:T类型元素的空列表
initializer_list<T> lst{a, b, c…}; lst的元素数量和初始值一样多;lst的元素时对应初始值的副本;列表中的元素是const的
lst2(lst); 或 lst2 = lst; 拷贝或赋值一个initializer_list对象但不会拷贝列表中的元素;拷贝后,原始列表和副本共享元素
lst.size(); 列表中元素数量
lst.begin() 返回指向lst中首元素的指针
lst.end() 返回指向lst中的尾后指针
    initializer_list<int> ls1 = { 0,1,2,3 };
    initializer_list<int> ls2 = ls1;

    auto a = ls1.begin();    //迭代器的类型为const int*

    if (ls1.begin() == ls2.begin()) {
        cout << "两者指向的数相同" << endl;    //会输出,说明ls1和ls2在内存中共用一个实体
    }

initializer_list中的值永远是常量值,我们无法改变initializer_list中的元素值。

如果我们想向initializer_list类型形参传递一个值的序列,则必须把序列放在一对花括号内:

void fun(initializer_list<string> il) {}    //定义

fun({"hahaha", "ssss"});    //调用

含有initializer_list类型形参的函数同时也可以拥有其他形参。

省略符形参是为了便于C++程序访问某些特殊的C代码而设置的,这些代码使用了名为varargs的C标准库功能。省略符形参只能出现在形参列表的最后一个位置:

void foo(int i, ...);
void foo(int i ...);    //等价于上句代码,i和...之间的空格非必须

void foo(...);

省略符形参应该仅仅用于C和C++通用的类型,大多数类类型的对象在传递给省略符形参时都无法正常使用。

计算所有输入的值之和:

#include <iostream>
using namespace std;

int count(initializer_list<int> il) {
    int count = 0;
    for (const int* beg = il.begin(); beg < il.end(); ++beg) {
        count += *beg;
    }
    return count;
}

int main() {
    cout << count({ 2,3,4 }) << endl;
    return 0;
}

initializer_list中的对象类型:

void func(initializer_list<string> il) {
    for (auto s : il);    //s的类型为string

    for (auto &s : il);    //最好写为这样,避免了重新创建对象并赋值。此时s类型为const string&
}

return语句终止当前正在执行的函数并将控制权返回到调用该函数的地方。

return语句两种形式:

return;    //用在返回类型为void的函数中
return expression;

返回类型为void的函数不一定有return语句,这类函数的最后一句后面会隐式地执行return。如果想在void函数的中间位置提前退出可以用return语句。

返回值为void的函数也可以使用第二种形式,但expression必须是另一个返回void类型的函数,否则会产生编译错误。

只要函数的返回类型不是void,则该函数内每条return语句必须返回一个值,返回值类型必须与函数返回值类型相同或能隐式地转换成函数的返回值类型。

有的编译器无法检查出以下错误:

int func() {
    if (1 == 2) {
        return 1;
    }
    //此函数运行时的行为是未定义的
}

返回一个值的方式和初始化一个变量或形参的方式完全一样,返回的值用于初始化调用点的一个临时量,该临时量就是函数调用的结果。

const string &ShorterString(const string &s1, const string &s2) {
    return s1.size() <= s2.size() ? s1 : s2;
}    //不管是调用函数还是返回结果都不会真正拷贝string对象

不要返回局部对象的引用或指针。函数结束后,它所占用的存储空间也随之被释放掉,意味着局部变量的引用和指针将不再有效。局部对象也包括字面值对象。

当函数返回类型为类类型时,我们可以这样获取返回类型的成员:

int sz = ShorterString(s1, s2).size();    //调用运算符优先级与点运算符和箭头运算符相同,且都符合左结合律

函数的返回类型决定函数调用是否是左值,当调用的函数的返回类型为引用时得到左值,其他返回类型都是右值:

char &get_val(string &str, string::size_type ix) {
    return str[ix];
}

int main() {
    string s("a value");
    get_val(s, 0) = 'A';
    cout << s << endl;    //输出A value
    return 0;
}

但如果返回类型是常量引用,我们就不能像上面一样给函数的返回结果赋值。

C++11新标准规定,函数可以返回花括号包围的值的列表。与其它返回结果相似,此处的列表也用来对表示函数返回的临时量进行初始化:

vector<string> func() {
    return {};
}

int main() {
    vector<string> vs = func();    //vs初始化为空vector
    cout << vs.size() << endl;    //输出0
    return 0;
}

如果函数的返回值是内置类型,则花括号内最多包含一个值,而且该值所占空间不应该大于目标类型的空间(不允许窄化转换)。如返回的是类类型,由类本身定义初始值如何使用。

如果函数的返回类型不是void,那么它必须有一个返回值,但这条规则有一个例外,就是main函数,我们允许main函数没有return语句直接结束。如果控制到达了main函数的结尾处而且没有return语句,编译器将隐式地插入一条返回0的return语句。

main函数的返回值可以看做是状态指示器,返回0表示执行成功,返回其他值表示执行失败,其中非0值的具体含义依机器而定。为了使返回值与机器无关,cstdlib头文件定义了两个预处理变量(编译前预处理器会将其替换为指定值),用来表示成功与失败:

int main() {
    if (some_failure) {
        return EXIT_FAILURE;
    }
    else {
        return EXIT_SUCCESS;     
    }
}

因为它们是预处理变量,所以既不能在前面加上std::,也不能在using声明中出现。

递归:函数调用自身。在递归函数中,一定有一条路径是不包含递归调用的,否则,函数将永远递归下去。

main函数不能调用它自己。

递归输出vector中对象:

#include <iostream>
#include <vector>
using namespace std;

void PrintVectorRecursively(vector<int> &iv, vector<int>::iterator it) {    //输入形参vector对象必须是引用,否则每次递归都会重新创建一个新vector,从而无法比较it和尾后指针
    if (it == iv.end()) {
        return;
    }
    cout << *it++ << endl;
    PrintVectorRecursively(iv, it);
}

int main() {
    vector<int> iv = { 0,1,2,3,4,5,6,7,8,9 };
    PrintVectorRecursively(iv, iv.begin());
}

数组不能被拷贝,因此函数不能返回数组,但可以返回数组的指针或引用,但写起来比较繁琐,我们可以使用类型别名来简化:

typedef int arrT[10];
using arrT = int[10];    //C++11新标准,与上句含义相同

arrT *func(int i);    //返回一个指向10个元素数组的指针
int (*func(int i))[10];    //不使用类型别名,与上句含义相同

C++11新标准中还有一种方法简化上边func不使用类型别名时的声明方法,即使用尾置返回类型。任何函数的定义都能使用尾置返回:

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

我们知道数组名常用作指向数组首地址的指针:

int ia[3];
int *(p)[3] = &ia;    //正确
int *(p2)[3] = &ia[0];    //错误,虽然&ia和&ia[0]的值相等,但他们的类型却不相等

如果我们知道函数返回的指针将指向哪个数组,就可以使用decltype关键字声明返回类型:

int odd[] = {1, 3, 5, 7, 9};
decltype(odd) *arrPtr(int i) {
    return &odd;
}

返回包含10个string类型的数组的引用的函数声明:

typedef string sa[10];
string s[10];

string(&func())[10];
sa& func1();
decltype(s) &func2();

如果同一作用域内的几个函数名字相同但形参列表不同,我们称之为重载函数。当调用这些函数时,编译器会根据传递的实参类型推断想要的是哪个函数。main函数不能重载。

对于重载的函数来说,它们应该在形参数量或形参类型上有所不同。不允许根据返回类型来重载函数,函数重载应在调用时就能分出想要使用的函数:

int func(int i);
void func(int i);    //不能重载

形参名不同但形参类型相同也不能重载,因为在调用时无法分清:

typedef int iii;
int func(int i);
int func(int j);    //不能重载
int func(iii k);    //不能重载

由于顶层const形参不影响传入的函数对象,因此顶层const形参不能与没有顶层const的同类形参区分开:

int func(const int i);
int func(int i);    //不能重载

但对于底层const,如引用或指针,就可以区分开两个函数:

int func(int &i);
int func(const int &i);    //新函数

int func1(int *i);
int func1(const int *i);    //新函数

对于以上四个函数,都可以接受不带const的实参,但编译器会优先选择非常量版本的函数,若输入的是带const的实参,那么只能选择const版本形参的函数,因此可以区分开两个函数。

尽管函数重载能一定程度上减轻我们为函数起名字、记名字的负担,但最好只重载那些做相同操作的函数,否则会让人难以理解。

const_cast与重载:当以下函数的参数和返回类型都是const string的引用时:

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

我们想如果输入的实参不是const类型时,输出为普通引用,就可以用const_cast:

string &ShorterString(string &s1, string &s2) {
    const string &r = ShorterString(const_cast<const string&>(s1), const_cast<const string&>(s2));
    return const_cast<string&>(r);
}

这个版本的函数中,调用了const string&版本的函数,首先把它的实参转换成了const的引用,然后把返回的结果又转换回了普通的引用,这显然是安全的。

函数匹配指我们把函数调用与一组重载函数中的某一个关联起来的过程。函数匹配也叫函数确定。编译器首先将调用的实参与重载集合中的每一个函数的形参进行比较,然后根据比较结果调用函数。

重载与作用域:

string read();
void print(const string&);
void print(double);

void func(int ival) {
    bool read = false;    //隐藏了外层的read函数
    string s = read();    //错误,此处read是一个布尔值
    void print(int);    //不好的习惯,在局部作用域中声明函数,隐藏了外层的print函数
    print("Value: ");    //错误,现在print(const string&)被隐藏了
    print(ival);    //正确,调用了函数void print(int);
    print(3.14);    //正确,但调用的函数是void print(int);
}

以上说明在C++中,名字查找发生在类型检查之前。

某些函数的形参在函数的很多次调用中它们都被赋予一个相同的值,此时,我们可以把这个反复出现的值作为函数的默认实参。调用含有默认实参的函数时,可以包含该实参,也可以省略该实参:

void func(int i = 5; string s = "Hello");

一旦某个形参被赋予了默认值,它后面所有形参都必须有默认值:

void func(int i = 5; string s);    //错误

如果我们想使用默认实参,只需要省略该实参就可以了:

func(2);    //调用void func(int i = 5; string s = "Hello");i被赋值为2,s使用默认实参“Hello”

在设计含有默认实参的函数时,要把常用的默认实参放到后面,不常用的放在前面。

函数声明时可以增加默认实参:

void func(int i) {
    cout << i << endl;
}

void func(int j = 2);    //声明时添加默认实参

int main() {
    func();    //使用默认实参2
}

但多次声明时,在给定的作用域内,一个形参只能被赋予一次默认实参,后续的函数声明只能为之前没有默认实参的形参添加默认实参:

void func(int, int, char = ' ');
void func(int, int, char = 'a');    //错误,重复添加默认实参
void func(int = 2, int = 3, char = ' ');      //错误,重复添加char默认实参
void func(int = 2, int = 3, char);    //正确添加

虽然多次声明一个函数是合法的,但最好在函数的声明中指定默认实参并把声明放到头文件中。

定义时可以不加默认实参,声明时加也可以:

void func(int = 2);    //即使声明在定义前面,此处的默认实参也生效

void func(int i) {
    cout << i << endl;
}

int main() {
    func();
    return 0;
}

局部变量不能作为默认实参,但全局变量可以:

int i = 80;
char def = ' ';
int func();    //这三个声明位于函数之外
string screen(int = func(), int = i, char = def);
string window = screen();    //调用screen(func(), 80, ' ')

void f2() {
    def = '*';
    int i = 100;
    window = screen();    //调用screen(func(), 80, '*')
                          //我们在f2内部改变了def的值,所以对screen调用时会传递这个更新过的值
                          //f2内部还声明了一个局部变量用于隐藏外层的i,但该局部变量与传递给screen的实参没有任何关系
}

把以上ShorterString这种规模较小的操作定义成函数有很多好处:
1.阅读和理解函数名比读懂等价的条件表达式容易的多。
2.使用函数可以确保行为的统一,每次操作都能保证按同样的方式进行。
3.如我们想修改计算过程,显然修改函数要比先找到等价表达式出现的所有地方然后再逐一修改更容易。
4.函数可以被其他应用重复使用,省去了重新编写的代价。
然而使用函数也有缺点,一般会比求等价表达式的值要慢一些。大多机器上,一次函数调用其实包含着一系列工作,如调用前保存寄存器、返回时恢复、可能需要拷贝实参、程序转向一个新的位置运行。

对于以上缺点,可以使用内联函数解决。将函数指定为内联函数,通常就是将它在每个调用点上内联地展开:

cout << ShorterString(s1, s2) << endl;
cout << (s1.size() < s2.size() ? s1 : s2) << endl;    //若ShorterString是内联函数,相当于这样

从而消除了函数运行的开销。内联函数声明如下:

inline const string &ShorterString(const string &s1, const string &s2) {    //内联inline关键字在定义函数时才有效,声明时是无效的
    return s1.size() <= s2.size() ? s1 : s2;
} 

内联只是向编译器发出的一个请求,编译器可以忽略这个请求。一般,内联函数用于优化那些规模较小、流程直接、频繁调用的函数。很多编译器都不支持内联递归函数,一个很长的如75行的函数也不大可能在调用点内联地展开。在调用内联函数之前必须已经出现了整个函数的定义,而不能只出现内联函数的声明。

constexpr函数指能用于常量表达式的函数。定义constexpr函数时,函数的返回类型及所有形参的类型都得是字面值类型,而且函数体中有且只有一条return语句:

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

以上代码中,因为编译器能在程序编译时验证函数的返回值是常量表达式,所以可以用此函数的返回值初始化constexpr类型变量ci。

constexpr函数体中也可以有其它语句,只要这些语句在运行时不执行任何操作就行,如空语句、类型别名、using声明。

我们也允许constexpr函数返回值并非一个常量:

constexpr int scale(int cnt) {
    return newsz() * cnt;
}    //当输入的实参是常量表达式时,返回值才是常量表达式

int arr[scale(2)];    //正确,scale(2)是常量表达式

int i = 2;
int arr2[scale(i)];    //错误,scale(i)不是常量表达式,因为i不是常量表达式

和其他函数不同,内联函数和constexpr函数可以在程序中定义多次,因为编译器要想展开函数仅有声明是不够的,还需要函数的具体定义,但对于某个给定的内联函数或constexpr函数来说,它的多个定义必须完全一致,因此,内联函数和constexpr函数通常定义在头文件中。

C++有时会用到头文件保护的技术,在程序中可以包含一些用于调试的代码,但是这些代码只在开发程序时使用,当应用程序编写完成准备发布时,要先屏蔽掉调试代码。这种方法需用到两项预处理功能:assert和NDEBUG。

assert是一种预处理宏,预处理宏是一个预处理变量,它的行为类似于内联函数。assert宏使用一个表达式作为他的条件:

assert(expr);

首先对expr求值,如表达式为假,assert输出信息并终止程序的执行,如为真,assert什么也不做。

assert宏定义在cassert头文件中。预处理名字由预处理器而非编译器管理,因此我们可以直接使用预处理名字而无须提供using声明,即我们应该使用assert而非std:assert,也不需要为assert提供using声明。

和预处理变量一样,宏名字必须在程序内唯一。含有cassert头文件的程序不能再定义名为assert的变量、函数或其他实体。实际编程过程中,即使我们没有包含cassert头文件,也最好不要为了其它目的使用assert,很多头文件都包含了cassert,这意味着即使你没有包含cassert头文件,它也很可能通过其他途径包含在你的程序中。

assert的行为依赖于一个名为NDEBUG的变量的状态。如果定义了NDEBUG,则assert什么也不做,默认状态下没有定义NDEBUG,此时assert将执行运行时检查。

我们可以使用#define语句定义NDEBUG:

#define NDEBUG    //定义NDEBUG的语句必须在包含cassert头文件之前才有效
#include <cassert>

同时很多编译器都提供了一个命令行选项使我们可以定义预处理变量,相当于在main.c文件的一开始写#define NDEBUG:

$ CC -D NDEBUG main.C    # use /D with Microsoft compiler

assert应该仅用于验证那些确实不可能发生的事(非正常的事)。

除了用于assert之外,也可以使用NDEBUG编写自己的条件调试代码,如果NDEBUG未定义,将执行#ifndef和#endif之间的代码,如果定义了NDEBUG,#ifndef和#endif之间的代码将被忽略:

void print(const int ia[], int size) {
    #ifndef NDEBUG
        cerr << __func__ << ": array size is " << size << endl;
    #endif
}

以上代码中,使用变量__func__输出当前调试的函数的名字。编译器为每个函数都定义了__func__,他是const char的一个静态数组(静态数组即在栈上分配的数组,[]定义的;动态数组时new定义的,分配在堆上,效率较低,但堆的可用空间大),用于存放函数的名字。

除了C++编译器定义的__func__之外,预处理器还定义了另外4个对于程序调试很有用的名字:
1.__FILE__存放文件名的字符串字面值(const char *)。
2.__LINE__存放当前行号的整形字面值。
3.__TIME__存放文件编译时间的字符串字面值。
4.__DATE__存放文件编译日期的字符串字面值。

大多数情况下,我们能确定某次调用应该选哪个重载函数,然而,当几个重载函数的形参数量相等以及某些形参的类型可以由其他类型转换得来时,这项工作就没那么容易了:

void f();
void f(int);
void f(int, int);
void f(double, double = 3.14);
f(5.6);    //调用void f(double, double = 3.14)

函数匹配的第一步是选定本次调用对应的重载函数集,集合中的函数称为候选函数。候选函数具备两个特征:一是与被调用的函数重名,二是其声明在调用点可见。以上例子中,有4个名为f的候选函数。

第二步是考察本次调用提供的实参,然后从候选函数中选出能被这组实参调用的函数,这些新选出的函数被称为可行函数。可行函数具备两个特征:一是其形参数量与本次调用提供的实参数量相等,二是每个实参的类型与对应的形参类型相同或能转换成对应形参的类型。

以上例子中,我们能根据实参的数量从候选函数中排除掉两个:不使用形参的函数和使用两个int形参的函数显然都不适合本次调用。而使用一个int形参的函数和使用两个double形参的函数是可行的,它们都能用一个实参调用,其中最后那个函数本应该接受两个double类型值,但因为它含有一个默认实参,所以用一个实参也能调用它:
·f(int)是可行的,因为实参类型double能转换为形参类型int。
·f(double, double)是可行的,因为它第二个形参提供了默认值,而第一个形参类型正好是double。

如没找到可行函数,编译器报告无匹配函数错误。

第三步是从可行函数中选择与本次调用最匹配的函数。这一过程中,逐一检查函数调用提供的实参,寻找形参类型与实参类型最匹配的那个可行函数。我们的例子中,调用只提供了一个实参,它的类型是double,如果调用f(int),实参将不得不从double转换成int,而另一个可行函数f(double, double)则与实参精确匹配。精确匹配比需要类型转换的匹配更好,因此编译器把f(5.6)解析成对含有两个double形参函数的调用,并使用默认值填补我们未提供的第二个形参。

当实参的数量有两个或更多时,函数匹配就比较复杂了:

f(42, 2.56);

上例中,可行函数包括f(int, int)和f(double, double),接下来,编译器确定哪个可行函数是最佳匹配,条件如下:
1.该函数每个实参的匹配都不劣于其它可行函数需要的匹配。
2.至少有一个实参的匹配优于其他可行函数提供的匹配。
如果在检查了所有实参之后没有任何一个函数脱颖而出,则该调用是错误的。编译器将报告二义性调用信息。在上例调用中,只考虑第一个实参时我们发现函数f(int, int)能精确匹配,要想匹配第二个函数,int类型的实参必须转换成double类型,显然需要内置类型转换的匹配劣于精确匹配,因此仅就第一个实参来说,f(int, int)比f(double, double)更好。而在考虑第二个实参2.56时,函数f(double, double)比f(int, int)要好。编译器最终将因为这个调用具有二义性而拒绝请求。看起来我们似乎可以使用强制类型转换其中的一个实参完成函数的匹配,而在设计良好的系统中,不应该对实参进行强制类型转换。

为了确定最佳匹配,编译器将实参类型到形参类型的转换划分为以下几个等级:
1.精确匹配:实参类型和形参类型相同;实参从数组类型或函数类型转换为对应的指针类型;向实参添加顶层const或者从实参删除顶层const。
2.通过const转换实现的匹配。(即const int引用类型可以接受int引用或const int引用的实参,但对于int类型实参优先使用int引用)
3.通过类型提升实现的匹配。(把char、unsigned char、short、unsigned short转换成int类型称为类型提升(promotion))
4.通过算术类型转换或指针转换实现的匹配。(long double、double、float、unsigned long long、long long、unsigned long、long、unsigned int、int之间的转换称为类型转换)
5.通过类类型转换实现的匹配。

上述规则中3、4的例子:

void func(int a) {
	cout << "int";
}

void func(double a) {
	cout << "double";
}

int main() {
	char a = 'a';
	func(a);    //a为char可以转化为int和double,但类型提升优先,因此调用第一个
}

小整型一般会提升到int类型或更大的整数类型:

void ff(int);
void ff(short);
ff('a');    //调用ff(int),只有输入的类型为short时才会选择short版本

所有算术类型转换的级别都一样:

void manip(long);
void manip(float);
manip(3.14);    //二义性调用,3.14类型为double,double转换为long和float都是算术类型转换

函数指针指向的是函数而非对象。函数的类型由返回类型和形参类型共同决定:

bool lengthCompare(const string&, const string &);    //该函数的类型是bool(const string&, const string&)

声明一个可以指向该函数的指针:

bool (*pf)(const string&, const string&);    //未初始化 

令函数指针指向函数:

pf = lengthCompare;
pf = &lengthCompare;    //等价于上句代码,取地址符可选

使用函数指针调用函数:

bool b1 = pf("hello", "goodby");
bool b2 = (*pf)("hello", "goodby");
bool b3 = lengthCompare("hello", "goodby");    //三个等价调用

在指向不同函数类型的指针间不存在转换规则,但可以为函数指针赋一个nullptr或值为0的整型常量表达式。

重载函数使用函数指针时,指针类型必须与重载函数中的某一个精确匹配:

void ff(int *);
void ff(unsigned int);

void (*pf1)(unsigned int) = ff;    //pf1指向ff(unsigned int)
void (*pf2)(int) = ff;    //错误
double (*pf3)(int *) = ff;    //错误

和数组类似,虽然不能定义函数类型的形参,但形参可以是指向函数的指针:

void useBigger(int i, bool pf(const string&const string&));
void useBigger(int i, bool (*pf)(const string&, const string&));    //与上句代码等价

调用形参中有函数指针的函数:

useBigger(1, lengthCompare);    //lengthCompare自动转换为函数指针

使用函数指针显得冗长,可以用类型别名和decltype简化函数指针的使用:

//Func是函数类型
typedef bool Func(const string&, const string&);    //bool(const string&, const string&)类型别名设为Func
typedef decltype(lengthCompare) Func2;    //与上句代码等价
//FuncP是函数指针类型
typedef bool (*FuncP)(const string&, const string&);    //bool (*pf)(const string&, const string&)的类型的类型别名设为FuncP
typedef decltype(lengthCompare) *FuncP2;    //与上句等价,decltype返回函数类型,在结果前面加上*表示函数指针

用了类型别名后声明形参中有函数指针的函数:

void useBigger(int i, Func);    //编译器自动将函数类型转换为函数指针
void useBigger(int i, FuncP);    //与上句等价

和数组类型类似,函数虽然不能返回一个函数,但可以返回函数指针,声明一个返回类型为函数指针的函数:

int (*f1(int))(int *, int);    //函数返回类型为指向int(int *, int)类型函数的指针
auto f1(int) -> int (*)(int *, int);    //与上句等价,尾置返回类型

定义声明一个返回类型为函数指针的函数比较繁琐,可以使用类型别名简化:

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

PF f1(int);    //正确
F f1(int);    //错误,F是函数类型
F *f1(int);    //正确

如果我们知道返回的函数是哪个,就能使用decltype简化书写,如有两个函数,他们返回类型都是int,并且两个函数的形参相同,若此时我们编写第三个函数,它的返回类型是指向前两个函数其中一个的指针:

int funca(int);
int funcb(int);

decltype(funca) *funcc(const string &);

例子:

#include <iostream>
using namespace std;

int add(int i, int j) {
	return i + j;
}

int subtract(int i, int j) {
	return i - j;
}

int multiply(int i, int j) {
	return i * j;
}

int devision(int i, int j) {
	return i / j;
}

int main() {
	vector<int (*)(int, int)> vp;
	vp.push_back(add);
	vp.push_back(subtract);
	vp.push_back(multiply);
	vp.push_back(devision);

	vector<int (*)(int, int)>::iterator b = vp.begin(), e = vp.end();
	while (b != e) {
		cout << (*b)(10, 2) << endl;
		++b;
	}
}
发布了193 篇原创文章 · 获赞 11 · 访问量 6万+

猜你喜欢

转载自blog.csdn.net/tus00000/article/details/104288410
今日推荐