【C++ Primer】第 6 章 函数

Spring-_-Bear 的 CSDN 博客导航

六、函数

函数是一个命名的代码块,我们通过调用函数执行相应的代码。函数可以有 0 个或多个参数,而且(通常)会产生一个结果。可以重载函数,也就是说,同一个名字可以对应几个不同的函数。

6.1 函数基础

一个典型的函数定义包括以下部分:

  • 返回类型
  • 函数名字
  • 形参列表
  • 函数体

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

  1. 编写函数:

    // 求 val 的阶乘
    int fact(int val) {
        int ret = 1;
        while (val > 1) {
            ret *= val--;
        }
        return ret;
    }
    
  2. 调用函数:函数的调用完成两项工作:一是用实参初始化函数对应的形参,二是将控制权转移给被调函数。此时,主调函数的执行被暂时中断,被调函数开始执行

    int main() {
        int r = fact(5);
        std::cout << "5! = " << r << std::endl;
        return 0;
    }
    
  3. 形参和实参:尽管实参与形参存在对应关系,但是并没有规定实参的求值顺序。编译器能以任意可行的顺序对实参求值。实参的类型必须与对应的形参的类型匹配

  4. 函数的形参列表:函数的形参列表可以为空,但是不能省略。要想定义一个不带形参的函数,最常用的办法是书写一个空的形参列表。不过为了与 C 语言兼容,也可以使用关键字 void 表示函数没有形参。

    形参列表中的形参通常用逗号隔开,其中每个形参都是含有一个声明符的声明。即使两个形参的类型一样,也必须把两个类型都写出来。

  5. 函数返回类型:大多数类型都能用作函数的返回类型。一种特殊的返回类型是 void,它表示函数不返回任何值。函数的返回类型不能是数组类型或函数类型,但可以是指向数组或函数的指针。

6.1.1 局部对象

C++ 语言中,名字有作用域,对象有生命周期:

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

形参和函数体内部定义的变量统称为局部变量。它们对函数而言是“局部”的,仅在函数的作用域内可见,同时局部变量还会隐藏在外层作用域中同名的其他所有声明中。

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

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

    对于局部对象对应的自动对象来说,如果变量对应本身不含初始值,执行默认初始化。这意味着内置类型的未初始化局部变量将产生未定义的值。

    int main() {
        int a;
        std::cout << a << std::endl;    // Variable 'a' is uninitialized when used here
        return 0;
    }
    
  2. 局部静态对象:某些时候,有必要令局部变量的生命周期贯穿函数调用及之后的时间。可以将局部变量定义成 static 类型从而获得局部静态对象。局部静态对象在程序的执行路径第一次经过对象定义语句时初始化,并且直到程序终止才被销毁,在此期间即使对象所在的函数结束运行也不会对它有影响。

    #include <iostream>
    
    size_t count_calls() {
        static size_t count = 0;
        return ++count;
    }
    
    int main() {
        for (size_t i = 0; i != 10; ++i) {
            // 1 2 3 4 5 6 7 8 9 10
            std::cout << count_calls() << " ";
        }
        return 0;
    }
    

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

6.1.2 函数声明

和其他名字一样,函数的名字也必须在使用之前声明。类似于变量,函数只能定义一次,但可以声明多次。唯一的例外是,如果一个函数永远也不会被我们用到,那么它可以只有声明没有定义。

函数的声明和函数的定义非常类似,唯一的区别是函数声明无须函数体,用一个分号替代即可。

因为函数的声明不包含函数体,所以也就无须形参的名字。事实上,在函数的声明中经常省略形参的名字。尽管如此,写上形参的名字还是有用处的,它可以帮助使用者更好地理解函数的功能:

void print(std::vector<int>::const_iterator beg, std::vector<int>::const_iterator end);

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

  1. 在头文件中进行函数声明:把函数声明放在头文件中,就能确保同一函数的所有声明保持一致。而且一旦我们想改变函数的接口,只需改变一条声明即可。定义函数的源文件应该把含有函数声明的头文件包含进来,编译器负责验证函数的定义和声明是否匹配。

6.1.3 分离式编译

为了允许编写程序时按照逻辑关系将其划分开来,C++ 语言支持所谓的分离式编译。分离式编译允许我们把程序分隔到几个文件中去,每个文件独立编译。

  1. 编译和链接多个源文件:大多数编译器提供了分离式编译每个文件的机制,这一过程通常会产生一个后缀名是 .obj.o 的文件,后缀名的含义是该对象包含对象代码

    fact.h

    #ifndef HEADER_FACT
    #define HEADER_FACT
    
    int fact(int);
    
    #endif
    

    fact.cpp

    #include "fact.h"
    
    // 求 val 的阶乘
    int fact(int val) {
        int ret = 1;
        while (val > 1) {
            ret *= val--;
        }
        return ret;
    }
    

    main.cpp

    #include <iostream>
    #include "fact.h"
    
    int main() {
        int r = fact(5);
        std::cout << "5! = " << r << std::endl;
        return 0;
    }
    
    # 编译 fact.cpp 得到 fact.o
    g++ -c fact.cpp
    # 编译 main.cpp 得到 main.o
    g++ -c main.cpp
    # 链接 main.o 和 fact.o 得到目标文件 main
    g++ main.o fact.o -o main
    # 运行目标文件
    ./main
    

6.2 参数传递

形参的类型决定了形参与实参交互的方式。如果形参是引用类型,它将绑定到对应的实参上。否则,将实参的值拷贝后赋给形参。

当形参是引用类型时,我们说它对应的实参被引用传递或者函数被传引用调用。和其他引用一样,引用形参也是它绑定的对象的别名;也就是说,引用形参是它对应的实参的别名。

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

6.2.1 传值参数

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

  1. 指针形参:指针的行为和其他非引用类型一样。当执行指针拷贝操作时,拷贝的是指针的值。拷贝之后,两个指针是不同的指针。因为指针可以使我们间接地访问它所指的对象,所以可以通过指针修改它所指的值:

    #include <iostream>
    
    void reset(int *ip) {
        *ip = 100;      // 改变指针所指对象的值
        ip = 0;         // 只改变了 ip 的局部拷贝,实参未被改变
    }
    
    int main() {
        int num = 10;
        std::cout << "before: " << num << std::endl;    // 10
        reset(&num);
        std::cout << "after: " << num << std::endl;     // 100
        return 0;
    }
    

    熟悉 C 的程序员常常使用指针类型的形参访问函数外部的对象。在 C++ 语言中,建议使用引用类型的形参替代指针。

6.2.2 传引用参数

通过使用引用形参,允许函数改变一个或多个实参的值:

#include <iostream>

void reset(int &ref) {
    ref = 100;
}

int main() {
    int num = 10;
    std::cout << "before: " << num << std::endl;    // 10
    reset(num);
    std::cout << "after: " << num << std::endl;     // 100
    return 0;
}
  1. 使用引用避免拷贝:拷贝大的类类型对象或者容器对象比较低效,甚至有的类类型(包括 IO 类型在内)根本就不支持拷贝操作。当某种类型不支持拷贝操作时,函数只能通过引用形参访问该类型的对象。

  2. 使用引用形参返回额外信息:一个函数只能返回一个值,引用形参为我们一次返回多个结果提供了有效的途径

    #include <iostream>
    #include <string>
    
    using std::string;
    
    /*
     * 返回 s 中 c 第一次出现的位置索引,未出现时返回 s.size()
     * 引用形参 occurs 负责统计 c 在 s 中出现的总次数
     */
    string::size_type find_char(const string &s, char c, string::size_type &occurs) {
        auto ret = s.size();
        occurs = 0;
        for (decltype(ret) i = 0; i != s.size(); ++i) {
            if (s[i] == c) {
                if (ret == s.size()) {
                    ret = i;
                }
                ++occurs;
            }
        }
        return ret;
    }
    
    int main() {
        string s = "hello world, hello china";
        string::size_type count;
        string::size_type index = find_char(s, 'h', count);
        std::cout << "index = " << index << ", count = " << count << std::endl;
        return 0;
    }
    

6.2.3 const 形参和实参

当形参是 const 时,必须要注意 2.4.3 节关于顶层 const 的讨论。顶层 const 作用于对象本身:

const int ci = 42;      // 不能改变 ci,const 是顶层的
int i = ci;             // 正确:当拷贝 ci 时,忽略了它的顶层 const

int *const p = &i;      // const 是顶层的,不能给 p 赋值,即不能改变指针 p 的指向
*p = 0;                 // 正确:可以改变指针 p 所指对象的内容

和其他初始化过程一样,当用实参初始化形参时会忽略掉顶层 const。换句话说,形参的顶层 const 被忽略掉了。当形参有顶层 const 时,传给它常量对象或者非常量对象都是可以的。

void func(const int i) {

}

// 函数重载失败,函数形参忽略顶层 const 后导致二者并无显著区别
void func(int i) {

}
  1. 指针或引用形参与 const:形参的初始化方式和变量的初始化方式是一样的。我们可以使用非常量初始化一个底层 const 对象,但是反过来不行;同时一个普通的引用必须用同类型的对象初始化

    int i = 42;
    const int *cp = &i;     // 正确:但是 cp 不能改变 i
    const int &r = i;       // 正确:但是 r 不能改变 i
    const int &r2 = 42;     // 正确
    int *p = cp;            // 错误:p 的类型与 cp 的类型不匹配
    int &r3 = r;            // 错误:r3 的类型与 r 的类型不匹配
    int &r4 = 42;           // 错误:不能用字面值初始化一个非常量引用
    

    将同样的初始化规则应用到参数传递上可得如下形式:

    #include <iostream>
    
    extern void reset(int *pInt);
    
    extern void reset(int &ref);
    
    extern std::string::size_type find_char(const std::string &s, char c, std::string::size_type &occurs);
    
    int main() {
        int i = 0;
        const int ci = i;
        std::string::size_type ctr = 0;
    
        reset(&i);          // 调用形参类型是 int* 的 reset 函数
        reset(&ci);         // 错误:不能用指向 const int 对象的指针初始化 int*
    
        reset(i);           // 调用形参类型是 int& 的 reset 函数
        reset(ci);          // 错误:不能把普通引用绑定到 const 对象的 ci 上
        reset(42);          // 错误:不能把普通引用绑定到字面值上
        reset(ctr);         // 错误:类型不匹配,ctr 是无符号类型
    
        // 正确:find_char 的第一个形参是对常量的引用
        find_char("Hello World", 'o', ctr);
    }
    
  2. 尽量使用常量引用:使用引用形参而非常量引用形参会极大地限制函数所能接受的实参类型

6.2.4 数组形参

数组的两个特殊性质对我们定义和使用作用在数组上的函数有影响,这两个性质分别是:

  • 不允许拷贝数组
  • 使用数组时(通常)会将其转换成指针

因为不能拷贝数组,所以我们无法以值传递的方式使用数组参数。因为数组会被转换成指针,所以当我们为函数传递一个数组时,实际上传递的是指向数组首元素的指针。

尽管不能以值传递的方式传递数组,但是我们可以把形参写成类似数组的形式。当编译器处理对 print 函数的调用时,只检查传入的参数是否是 const int* 类型:

/*
 * 尽管形式不同,但这三个 print 函数是等价的
 * 每个函数都有一个 const int* 类型的形参
 */

void print(const int *);        // 函数的意图是作用于一个整型指针

void print(const int[]);        // 函数的意图是作用于一个整型数组

void print(const int[10]);      // 函数的意图是作用于一个长度为 10 的整型数组,实际上不一定

因为数组是以指针的形式传递给函数的,所以一开始并不知道数组的确切尺寸,调用者应该为此提供一些额外的信息。管理指针形参有三种常用的技术:

  1. 使用标记指定数组长度:要求数组本身包含一个结束标记,典型示例是 C 风格字符串

    #include <iostream>
    
    void print(const char *cp) {
        if (cp) {                           // 若 cp 不是一个空指针
            while (*cp) {                   // 只要指针所指的字符不是空字符
                std::cout << *cp++ << " ";  // 输出当前字符并将指针向前移动一个位置
            }
            std::cout << std::endl;
        }
    }
    
    int main() {
        char s[] = {'h', 'e', 'l', 'l', 'o', '\0'};
        print(s);
        return 0;
    }
    
  2. 使用标准库规范:传递指向数组首元素和尾元素的指针

    #include <iostream>
    
    void print(const char *beg, const char *end) {
        // 输出 [beg, end) 之间的所有元素
        while (beg != end) {
            std::cout << *beg++ << " ";
        }
        std::cout << std::endl;
    }
    
    int main() {
        char s[] = {'h', 'e', 'l', 'l', 'o', '\0'};
        print(&s[0], &s[5]);
        print(std::begin(s), std::end(s));
        return 0;
    }
    
  3. 显式传递一个表示数组大小的形参:专门定义一个表示数组大小的形参

    #include <iostream>
    
    void print(const char arr[], std::size_t size) {
        for (std::size_t i = 0; i != size; ++i) {
            std::cout << arr[i] << " ";
        }
        std::cout << std::endl;
    }
    
    int main() {
        char s[] = {'h', 'e', 'l', 'l', 'o', '\0'};
        print(s, std::end(s) - std::begin(s));
        return 0;
    }
    
  4. 数组形参和 const:当函数不需要对数组元素执行写操作的时候,数组形参应该是指向 const 的指针。只有当函数确实要改变元素值的时候,才把形参定义成指向非常量的指针。

  5. 数组引用形参:C++ 语言允许将变量定义为数组的引用,基于同样的道理,形参也可以是数组的引用

    /*
     * 形参是数组的引用,维度是类型的一部分
     * &arr 两端的括号必不可少,否则就将 arr 声明成了引用的数组
     */
    void printArr(int (&arr)[5]) {
        for (const auto &item: arr) {
            std::cout << item << " ";
        }
        std::cout << std::endl;
    }
    
    int main() {
        int a[] = {1, 2, 3, 4, 5};
        printArr(a);
    }
    
  6. 传递多维数组:当将多维数组传递给函数时,真正传递的是指向数组首元素的指针

    #include <iostream>
    
    void print(int (*matrix)[3], int rowSize) {
        for (int i = 0; i != rowSize; ++i) {
            for (const auto &item: matrix[i]) {
                std::cout << item << " ";
            }
            std::cout << std::endl;
        }
    }
    
    int main() {
        int m[][3] = {
                {1, 2, 3},
                {4, 5, 6},
                {7, 8, 9}
        };
        print(m, 3);
        return 0;
    }
    

    我们也可以使用数组的语法定义函数,此时编译器会一如既往地忽略掉第一个维度,以下为等价定义:

    void print(int matrix[][3], int rowSize);
    

6.2.5 main:处理命令行选项

#include <iostream>

// 等价定义
int main(int argc, char **argv);

// argc 表示数组 argv 中元素的数量
int main(int argc, char *argv[]) {
    for (int i = 0; i != argc; ++i) {
        std::cout << "argc[" << i << "] = " << argv[i] << std::endl;
    }
    return 0;
}
[leoli@machine cpp-study]$ g++ -o main main.cpp 
[leoli@machine cpp-study]$ ./main -c application.yaml -author leoli
argc[0] = ./main
argc[1] = -c
argc[2] = application.yaml
argc[3] = -author
argc[4] = leoli

6.2.6 含有可变形参的函数

为了编写能处理不同数量实参的函数,C++11 新标准提供了两种主要的方法:

  • 如果所有的实参类型都相同,可以传递一个名为 initializer_list 的标准库类型
  • 如果实参的类型不同,可以编写一种特殊的函数,也即可变参数模板

C++ 还有一种特殊的形参类型,即省略符,可以用它传递可变数量的实参,不过需要注意的是,这种功能一般只用于与 C 函数交互的接口程序。

  1. initializer_list 形参:适用于实参数量未知但类型都相同。initializer_list 是一种标准库类型,用于表示某种特定类型的值的数组

    操作说明
    initializer_list<T> lst;默认初始化,初始化为 T 类型元素的空列表
    initializer_list<T> list{a,b,c...};lst 的元素和初始值一样多;lst 的元素是对应初始值的副本;列表中的元素是 const
    lst2(lst)拷贝或赋值一个 initializer_list 对象,不会拷贝列表中的元素;拷贝后,原始列表和副本共享元素
    lst2 = lstlst2(lst)
    lst.size()列表中的元素数量
    lst.begin()返回指向 lst 中首元素的指针
    lst.end()返回指向 lst 中尾元素下一位置的指针
    #include <initializer_list>
    #include <string>
    #include <iostream>
    
    using namespace std;
    
    // initializer_list 对象中的元素是常量值,无法修改
    void error_msg(initializer_list<string> lst, int error_code) {
        cout << "error_code: " << error_code << endl;
        for (auto beg = lst.begin(); beg != lst.end(); ++beg) {
            cout << *beg << " ";
        }
        cout << endl;
    }
    
    int main() {
        error_msg({"invalid order_id", "empty set", "mysql timeout"}, -5408);
        return 0;
    }
    
  2. 省略符形参:省略符形参是为了便于 C++ 程序访问某些特殊的 C 代码而设置的,这些代码使用了名为 varargsC 标准库功能。通常,省略符形参不应用于其他目的。省略符形参只能出现在形参列表的最后一个位置

    #include <cstdarg>
    #include <iostream>
    
    // 省略符形参函数示例
    void printNumbers(int count, ...) {
        va_list args;
        va_start(args, count); // 初始化 args 指向第一个可选参数
    
        for (int i = 0; i != count; ++i) {
            int num = va_arg(args, int); // 获取下一个参数,并指定其类型
            std::cout << num << " ";
        }
    
        va_end(args); // 清理
        std::cout << std::endl;
    }
    
    int main() {
        printNumbers(5, 1, 2, 3, 4, 5); // 输出: 1 2 3 4 5
        return 0;
    }
    

6.3 返回类型和 return 语句

6.3.1 无返回值函数

没有返回值的 return 语句只能用在返回类型是 void 的函数中。返回 void 的函数不要求非得有 return 语句,因为在这类函数的最后一句后面会隐式地执行 return

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

    int temp = v1;
    v1 = v2;
    v2 = temp;
}

6.3.2 有返回值函数

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

bool contains(const std::string &s1, const std::string &s2) {
    if (s1.size() == s2.size()) {
        return s1 == s2;
    }

    auto min_size = s1.size() < s2.size() ? s1.size() : s2.size();
    for (decltype(min_size) i = 0; i != min_size; ++i) {
        if (s1[i] != s2[i]) {
            return false;
        }
    }

    return true;
}
  1. 值是如何被返回的:返回一个值的方式和初始化一个变量或形参的方式完全一样:返回的值用于初始化调用点的一个临时量,该临时量就是函数调用的结果。同其他引用类型一样,如果函数返回引用,则该引用仅是它所引对象的一个别名。

    const std::string &get_shorter_string(const std::string &s1, const std::string &s2) {
        return s1.size() < s2.size() ? s1 : s2;
    }
    
  2. 不要返回局部对象的引用或指针:函数完成后,它所占用的存储空间也随之被释放掉。因此,函数终止意味着局部变量的引用将指向不再有效的内存区域。同理,返回局部对象的指针也是错误的。

  3. 返回类类型的函数和调用运算符:调用运算符的优先级与点运算符和箭头运算符相同,并且也符合左结合律。因此,如果函数返回指针、引用或类的对象,我们就能使用函数调用的结果访问结果对象的成员。

    #include <iostream>
    
    const std::string &get_shorter_string(const std::string &s1, const std::string &s2) {
        return s1.size() < s2.size() ? s1 : s2;
    }
    
    int main() {
        auto shorter_size = get_shorter_string("h", "b").size();
        std::cout << shorter_size << std::endl;
        return 0;
    }
    
  4. 引用返回左值:函数的返回类型决定函数调用是否是左值。调用一个返回引用的函数得到左值,其他返回类型得到右值。可以像使用其他左值那样来使用返回引用的函数的调用,特别是,我们能为返回类型是非常量引用的函数的结果赋值:

    #include <iostream>
    
    char &get_val(std::string &s, std::string::size_type idx) {
        return s[idx];
    }
    
    int main() {
        std::string s("hello world");
        std::cout << s << std::endl;    // hello world
        get_val(s, 0) = 'H';
        get_val(s, 6) = 'W';
        std::cout << s << std::endl;    // Hello World
        return 0;
    }
    
  5. 列表初始化返回值:C++11 新标准规定,函数可以返回花括号包围的值的列表

    std::vector<std::string> process(int size) {
        if (size <= 0) {
            return {};
        } else if (size < 10) {
            return {"okay"};
        } else {
            return {"warning", "error", "fatal"};
        }
    }
    

    如果函数返回的是内置类型,则花括号包围的列表最多包含一个值,而且该值所占空间不应该大于目标类型的空间:

    int list_initialization() {
        return {10};
    }
    
  6. 主函数 main 的返回值:允许 main 函数没有 return 语句直接结束。如果控制到达了 main 函数结尾处而且没有 return 语句,编译器将隐式地插入一条返回 0return 语句。

    main 函数的返回值可以看做是状态指示器。返回 0 表示执行成功,返回其他值表示执行失败,其中非 0 值的具体含义依机器而定。为了使返回值与机器无关,cstdlib 头文件定义了两个预处理变量,我们可以使用这两个变量分别表示成功与失败:

    #include <iostream>
    #include <cstdlib>
    
    int main(int argc, char *argv[]) {
        for (int i = 0; i != argc; ++i) {
            std::cout << i << ": " << argv[i] << std::endl;
        }
        return argc < 2 ? EXIT_FAILURE : EXIT_SUCCESS;
    }
    
  7. 递归:如果一个函数调用了它本身,不管这种调用是直接的还是间接的,都称该函数为递归函数:

    // 计算 val 的阶乘
    int factorial(int val) {
        if (val > 1) {
            return factorial(val - 1) * val;
        }
        return 1;
    }
    

6.3.3 返回数组指针

因为数组不能拷贝,所以函数不能返回数组。不过,函数可以返回数组的指针或引用。虽然从语法上来说,要想定义一个返回数组的指针或引用的函数比较繁琐,但是可以使用类型别名的方法简化这一任务:

// arrT 是一个类型别名,它表示的类型是含有 10 个整数的数组
typedef int arrT[10];
// arrT 的等价声明方式
using arrT = int[10];

// 函数 func 返回一个指向含有 10 个整数的数组的指针
arrT *func(int i);
  1. 声明一个返回数组指针的函数:要想在声明返回指针的函数时不使用类型别名,那么我们必须牢记被定义的名字后面数组的维度:

    int arr[10];            // arr 是一个含有 10 个整数的数组
    int *p1[10];            // p1 是一个含有 10 个指针的数组
    int (*p2)[10] = &arr;   // p2 是一个指针,指向含有 10 个整数的数组
    

    和这些声明一样,如果我们想定义一个返回数组指针的函数,则数组的维度必须跟在函数名字之后:

    int (*func(int i))[10];
    
    • func(int i) 表示调用 func 函数时需要一个 int 类型的实参
    • (*func(int i)) 意味着我们可以对函数调用的结果执行解引用操作
    • (*func(int i))[10] 表示解引用 func 的调用将得到一个大小是 10 的数组
    • int (*func(int i))[10] 表示数组中的元素是 int 类型
  2. 使用尾置返回类型:在 C++11 新标准中还有一种可以简化上述 func 声明的方法,就是使用尾置返回类型。任何函数的定义都能使用尾置返回,但是这种形式对于返回类型比较复杂的函数最有效:

    // 函数 func 接受一个 int 类型的实参,返回一个指针,该指针指向含有 10 个整数的数组
    auto func(int i) -> int (*)[10];
    
  3. 使用 decltype:如果我们知道函数返回的指针将指向哪个数组,就可以使用 decltype 关键字声明返回类型:

    int odd[] = {1, 3, 5, 7, 9};
    int even[] = {0, 2, 4, 6, 8};
    
    // 返回一个指针,该指针指向含有 5 个整数的数组
    decltype(odd) *arrPtr(int i) {
        return (i % 2) ? &odd : &even;
    }
    

    decltype 并不负责把数组类型转换成对应的指针,所以 decltype 的结果是个数组,需要手动加上解引用符和取地址符。

6.4 函数重载

如果同一作用域内几个函数的名字相同但形参列表不同,我们称之为重载函数。main 函数不能重载和递归。函数重载可以在一定程度上减轻程序员起名字、记名字的负担。

  1. 定义重载函数:函数返回值不能作为判断函数重载的依据

    void print(const char *cp);
    void print(const int *beg, const int *end);
    void print(const int ia[], size_t size);
    
  2. 判断两个形参的类型是否相异:判断形参的本质数据类型是否相同

    typedef std::string String;
    
    void print(std::string s);
    // 重复声明,不构成函数重载
    void print(String s);
    
  3. 重载和 const 形参:顶层 const 不影响传入函数的对象。一个拥有顶层 const 的形参无法和另一个没有顶层 const 的形参区分开来:

    void print(std::string s);
    // 重复声明,不构成函数重载
    void print(const std::string s);
    

    另一方面,如果形参是某种类型的指针或引用,则通过区分其指向是常量对象还是非常量对象可以实现函数重载,此时的 const 是底层的:

    void print(std::string &);
    void print(const std::string &);
    
    void print(std::string *);
    void print(const std::string *);
    

    当我们传递一个非常量对象或者指向非常量对象的指针时,编译器会优先选用非常量版本的函数。

  4. const_cast 和重载:const_cast 在重载函数的场景中最有用

    // 比较两个 string,返回较短的那个的常量引用
    const std::string &short_string(const std::string &s1, const std::string &s2) {
        return s1.size() < s2.size() ? s1 : s2;
    }
    
    std::string &shorter_string(std::string &s1, std::string &s2) {
        auto &r = short_string(const_cast<const std::string &>(s1), const_cast<const std::string &>(s2));
        return const_cast<std::string &>(r);
    }
    
  5. 调用重载的函数:定义了一组重载函数后,我们需要以合理的实参调用它们。函数匹配是指一个过程,在这个过程中我们把函数调用与一组重载函数中的某一个关联起来,函数匹配也叫函数确定。

6.4.1 重载与作用域

重载对作用域的一般性质并没有什么改变:如果我们在内层作用域中声明名字,它将隐藏外层作用域中声明的同名实体。在不同的作用域中无法重载函数名:

std::string read();

void print(const std::string &);
void print(double);

void fooBar(int val) {
    bool read = false;          // 隐藏了外部的 read()
    std::string s = read();     // 错误:read 是一个布尔值,而非函数

    // 不好的习惯:通常来说,在局部作用域中声明函数不是一个好的选择
    void print(int);            // 隐藏了外部的 print() 重载函数
    print("value: ");           // 错误
    print(3.14);                // 正确:3.14 -> 3
    print(val);                 // 正确
}

6.5 特殊用途语言特性

6.5.1 默认实参

调用含有默认实参的函数,可以包含该实参,也可以省略该实参。不过需要注意的是,一旦某个形参被赋予了默认值,则它后面的所有参数都必须有默认值:

typedef std::string::size_type st;

std::string screen(std::string background = "white", st width = 160, st height = 90);
  1. 使用默认实参调用函数:

    int main() {
        std::string window;
        window = screen();
        window = screen("red");
        window = screen("black", 1920);
        window = screen("green", 1920, 1080);
    }
    

    函数调用时实参按其位置解析,默认实参负责填补函数调用缺少的尾部实参。

  2. 默认实参声明:对于函数的声明来说,通常的习惯是将其放在头文件中,并且一个函数只声明一次,但是多次声明同一个函数也是合法的。不过有一点需要注意,在给定的作用域中一个形参只能被赋予一次默认实参。换句话说,函数的后续声明只能为之前那些没有默认参数值的形参添加默认实参,而且该形参右侧的所有形参必须都有默认值:

    typedef std::string::size_type st;
    
    std::string screen(st, st, char = ' ');
    std::string screen(st, st, char = '*');         // 错误:重复声明
    std::string screen(st = 24, st = 80, char);     // 正确:添加默认实参
    
  3. 默认实参初始值:局部变量不能作为默认实参。除此之外,只要表达式的类型能转换成形参所需的类型,该表达式就能作为默认实参。用作默认实参的名字在函数声明所在的作用域内解析,而这些名字的求值过程发生在函数调用时:

    #include <iostream>
    #include <sstream>
    
    typedef std::string::size_type sz;
    
    sz w() {
        return 1920;
    }
    
    sz h = 1080;
    char b = 'R';
    
    std::string screen(sz width = w(), sz height = h, char background = b) {
        std::ostringstream oss;
        oss << "width = " << width << " height = " << height << " background = " << background << std::endl;
        return oss.str();
    }
    
    int main() {
        std::cout << screen();  // width = 1920 height = 1080 background = R
        h = 920;
        b = 'W';
        std::cout << screen();  // width = 1920 height = 920 background = W
    }
    

6.5.2 内联函数和 constexpr 函数

调用函数一般比求等价表达式的值要慢一些。在大多数机器上,一次函数调用其实包含着一系列工作:调用前要先保存寄存器,并在返回时恢复;可能需要拷贝实参;程序转向一个新的位置继续执行。

  1. 内联函数可避免函数调用的开销:将函数指定为内联函数,通常就是将它在每个调用点上 “内联地” 展开:

    inline const std::string &shorter_string(const std::string &s1, const std::string &s2) {
        return s1.size() > s2.size() ? s1 : s2;
    }
    
    int main() {
        std::cout << shorter_string("hello", "world") << std::endl;
    
        // 在编译过程中展开成类似于下面的形式,从而消除了函数的运行时开销
        // std::cout << s1.size() > s2.size() ? s1 : s2 << std::endl;
    }
    

    内联说明只是向编译器发出的一个请求,编译器可以选择忽略这个请求。一般来说,内联机制用于优化规模较小、流程直接、频繁调用的函数。很多编译器都不支持内联递归函数。

  2. constexpr 函数:constexpr 函数是指能用于常量表达式的函数。需要遵循的约定如下:函数的返回类型及所有形参的类型都得是字面值类型,而且函数体中必须有且只有一条 return 语句:

    constexpr int new_sz() {
        return 42;
    }
    
    constexpr int foo = new_sz();   // 正确:foo 是一个常量表达式
    

    执行该初始化任务时,编译器把对 constexpr 函数的调用替换成其结果值。为了能在编译过程中随时展开,constexpr 函数被隐式地指定为内联函数。

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

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

    constexpr int new_sz() {
        return 42;
    }
    
    constexpr size_t scale(size_t cnt) {
        return new_sz() * cnt;
    }
    
  3. 把内联函数和 constexpr 函数放在头文件内:和其他函数不一样,内联函数和 constexpr 函数可以在程序中多次定义。毕竟,编译器要想展开函数仅有函数声明是不够的,还需要函数的定义。不过,对于某个给定的内联函数或者 constexpr 函数来说,它的多个定义必须完全一致。基于这个原因,内联函数和 constexpr 函数通常定义在头文件中。

6.5.3 调试帮助

C++ 程序员有时会用到一种类似于头文件保护的技术,以便有选择地执行调试代码。基本思想是,程序可以包含一些用于调试的代码,但是这些代码只在开发程序时使用。当应用程序编写完成准备发布时,要先屏蔽掉调试代码。这种方法用到两项预处理功能:assertNDEBUG

  1. assert 预处理宏:assert 是一种预处理宏。所谓预处理宏其实是一个预处理变量,它的行为有点类似于内联函数。assert 宏使用一个表达式作为它的条件,如果表达式为假,assert 输出信息并终止程序的执行;如果表达式为真则什么也不做。

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

    和预处理变量一样,宏名字在程序内必须唯一。含有 cassert 头文件的程序不能再定义名为 assert 的变量、函数或者其他实体。

    assert 宏常用于断言 “不能发生” 的条件:

    #include <iostream>
    #include <cassert>
    #include <string>
    
    const int threshold = 20;
    
    int main() {
        std::string word;
        while (std::cin >> word) {
            assert(word.size() > threshold);
        }
        return 0;
    }
    
  2. NDEBUG 预处理变量:assert 的行为依赖于一个名为 NDEBUG 的预处理变量的状态,如果定义了 NDEBUG,则 assert 什么也不做。默认状态下没有定义 NDEBUG,此时 assert 将执行运行时检查。

    我们可以使用一个 #define 语句定义 NDEBUG,从而关闭调试状态。同时,很多编译器都提供了一个命令行选项使我们可以定义预处理变量:

    # 这行命令的作用等价于在 main.cpp 文件的一开始写 #defind NDEBUG
    g++ -D NDEBUG -o main main.cpp
    

    NDEBUG 除了用于 assert 之外,我们也可以利用其编写自己的条件调试代码:

    void print(const int arr[], size_t size) {
    #ifdef NDEBUG
        // __func__ 是编译器定义的一个局部静态变量,用于存放函数的名字
        std::cerr << __func__ << ": array size is " << size << std::endl;
    #endif
    }
    

    除了 C++ 编译器定义的 __func__ 之外,预处理器还定义了另外四个对于程序调试很有用的名字:

    名字说明
    __FILE__存放文件名的字符串字面值
    __LINE__存放当前行号的整型字面值
    __TIME__存放文件编译时间的字符串字面值
    __DATE__存放文件编译日期的字符串字面值
    std::cerr << "Error in file " << __FILE__
              << " in function " << __func__
              << " at line " << __LINE__ << std::endl
              << "Compiled on " << __DATE__
              << " at " << __TIME__ << std::endl;
    

6.6 函数匹配

void f();
void f(int);
void f(int, int);
void f(double, double = 3.14);

f(5.16);    // 调用 void f(double, double = 3.14);
  1. 确定候选函数和可行函数:候选函数具备两个特征:一是与被调用的函数同名;二是其声明在调用点可见。可行函数也具备两个特征:一是其形参数量与本次调用提供的实参数量相等,二是每个实参的类型与对应的形参的类型相同,或者能转换成形参的类型

  2. 寻找最佳匹配(如果存在的话):逐一检查函数调用提供的实参,寻找形参类型与实参类型最匹配的那个可行函数(实参类型与形参类型越接近,它们匹配得越好)

  3. 含有多个形参的函数匹配:如果有且只有一个函数满足下列条件,则匹配成功:

    • 该函数每个实参的匹配都不劣于其他可行函数需要的匹配
    • 至少有一个实参的匹配优于其他可行函数提供的匹配

    如果在检查了所有实参之后没有任何一个函数脱颖而出,则该调用是错误的。编译器将报告二义性调用的信息:

    f(42, 2.56);    // Call to 'f' is ambiguous
    

6.6.1 实参类型转换

为了确定最佳匹配,编译器将实参类型到形参类型的转换划分成几个等级,具体排序如下所示:

  • 精确匹配,包括以下情况:
    • 实参类型和形参类型相同
    • 实参从数组类型或函数类型转换成对应的指针类型
    • 向实参添加顶层 const 或者从实参中删除顶层 const
  • 通过 const 转换实现的匹配
  • 通过类型提升实现的匹配
  • 通过算术类型转换实现的匹配
  • 通过类类型转换实现的匹配
  1. 需要类型提升和算术类型转换的匹配:小整型一般都会提升到 int 类型或更大的整数类型。有时候,即使实参是一个很小的整数值,也会直接将它提升成 int 类型:

    #include <iostream>
    
    void ff(int iVal) {
        std::cout << "int: " << iVal << std::endl;
    }
    
    void ff(short sVal) {
        std::cout << "sVal: " << sVal << std::endl;
    }
    
    int main() {
        ff('a');    // 调用:void ff(int iVal);输出:int: 97
    }
    
  2. 函数匹配和 const 实参:如果重载函数的区别在于它们的引用类型的形参是否引用了 const,或者指针类型的形参是否指向 const,则当调用发生时编译器通过实参是否是常量来决定选择哪个函数:

    void lookup(int &);
    
    void lookup(const int &);
    
    int main() {
        const int ciVal = 10;
        int iVal = 10;
        lookup(ciVal);  // 调用:void lookup(const int &);
        lookup(iVal);   // 调用:void lookup(int &);
    }
    

6.7 函数指针

函数指针指向的是函数而非对象。和其他指针一样,函数指针指向某种特定类型。函数的类型由它的返回类型和形参类型共同决定,与函数名无关:

bool length_compare(const std::string &, const std::string &);

// 声明一个指向函数的指针:用指针替换函数名即可
bool (*ptrFunc)(const std::string &, const std::string &);  // 未初始化
  1. 使用函数指针:当我们把函数名作为一个值使用时,该函数自动地转换成指针

    ptrFunc = length_compare;   // 函数指针 ptrFunc 指向函数 length_compare
    ptrFunc = &length_compare;  // 等价的赋值语句
    

    我们还能直接使用指向函数的指针调用该函数,无须提前解引用指针:

    bool b1 = ptrFunc("hello", "goodbye");          // 通过函数指针调用函数
    bool b2 = (*ptrFunc)("hello", "goodbye");       // 一个等价的调用
    bool b3 = length_compare("hello", "goodbye");   // 另一个等价的调用:通过函数名
    

    在指向不同函数类型的指针间不存在转换规则,但我们可以为函数指针赋值 nullptr 或者值为 0 的整型常量表达式,表示该指针没有指向任何一个函数:

    ptrFunc = nullptr;
    ptrFunc = 0;
    
  2. 重载函数的指针:当我们使用重载函数时,上下文必须清晰地界定到底应该选用哪个函数。编译器通过指针类型决定选用哪个函数,指针类型必须与重载函数中的某一个精确匹配:

    void ff(int *);
    void ff(unsigned int);
    
    void (*pf1)(unsigned int) = ff; // pf1 指向 void ff(unsigned int);
    
    void (*pf2)(int) = ff;          // 错误:没有任何一个 ff 与该形参列表匹配
    
    double (*pf3)(int *) = ff;      // 错误:ff 和 pf3 的返回类型不匹配
    
  3. 函数指针形参:和数组类似,虽然不能定义函数类型的形参,但是形参可以是指向函数的指针:

    bool length_compare(const std::string &, const std::string &);
    
    // 第三个形参是函数类型,它会自动地转换成指向函数的指针
    void use_bigger(const std::string &s1, const std::string &s2, bool pf(const std::string &, const std::string &));
    // 等价的声明:显式地将形参定义为指向函数的指针
    void use_bigger(const std::string &s1, const std::string &s2, bool (*pf)(const std::string &, const std::string &));
    
    int main() {
        // 直接把函数作为实参使用,它会自动转换成指针
        use_bigger("hello", "bye", length_compare);
    }
    

    直接使用函数指针类型显得冗长而繁琐。类型别名能让我们简化使用了函数指针的代码:

    bool length_compare(const std::string &, const std::string &);
    
    // func 和 func2 是函数类型
    typedef bool func(const std::string &, const std::string &);
    typedef decltype(length_compare) func2;
    
    // pFunc 和 pFunc2 是指向函数的指针
    typedef bool (*pFunc)(const std::string &, const std::string &);
    typedef decltype(length_compare) *pFunc2;   // decltype 返回函数类型,不会自动转换成指针,需要手动加上解引用符
    
  4. 返回指向函数的指针:和数组类似,虽然不能返回一个函数,但是能返回指向函数类型的指针。然而,我们必须把返回类型写成指针形式,编译器不会自动地将函数返回类型当成对应的指针类型处理:

    using func = int(int *, int);           // func 是函数类型
    using pFunc = int (*)(int *, int);      // pFunc 是指针类型
    
    pFunc f1(int);                          // 正确:pFunc 是指向函数的指针,f1 返回指向函数的指针
    func f2(int);                           // 错误:func 是函数类型,f2 不能返回一个函数
    func *f3(int);                          // 正确:显式地指定返回类型是指向函数的指针
    auto f4(int) -> int (*)(int *, int);    // 正确:使用尾置返回类型返回指向函数的指针
    
  5. autodecltype 用于函数指针类型:如果我们明确知道返回的函数是哪一个,就能使用 decltype 简化书写函数指针返回类型的过程。需要牢记的是当我们将 decltype 作用于某个函数时,它返回函数类型而非指针类型。因此,我们需要显式地加上解引用符以表明我们需要返回指针,而非函数本身。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

春天熊

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值