这一章在干嘛?

第 7 章学的是”C 就有的函数”,这一章全是 C++ 新增的函数特性:让函数”原地展开”的内联、给变量起别名的引用、能省略实参的默认参数、同名多形态的重载,以及”一次编写、处处适用”的函数模板。它们是 C++(plus 的部分)真正区别于 C 的地方,也是后续类设计的地基。

8.1 内联函数

8.2 引用变量

8.3 按引用传递

8.4 引用与结构体和类对象

8.5 何时使用引用

8.6 默认参数

8.7 函数重载

8.8 函数模板

8.9 模板进阶与 C++11 新工具

8.1 内联函数

普通函数调用是有代价的:程序要记住返回地址、把参数复制到栈、跳到函数地址执行、再跳回来。内联函数(inline function)让编译器直接把函数代码”复制粘贴”到调用处——不跳转,跑得更快,代价是代码变多(10 处调用就有 10 份副本)。

用法:在声明或定义前加 inline。惯用做法是省略原型,把整个定义放在原型该在的位置。编译器不保证照办——函数太大、有递归,都可能拒绝内联。


▲ 图 8.1 内联函数与常规函数的对比

// inline.cpp -- 使用内联函数
#include <iostream>
// 内联函数定义
inline double square(double x) { return x * x; }
int main()
{
    using namespace std;
    double a, b;
    double c = 13.0;
 
    a = square(5.0);
    b = square(4.5 + 7.5); // 可以传表达式
    cout << "a = " << a << ", b = " << b << "\n";
    cout << "c = " << c;
    cout << ", c squared = " << square(c++) << "\n";
    cout << "Now c = " << c << "\n";
    return 0;
}
a = 25, b = 144
c = 13, c squared = 169
Now c = 14

注意两点:① 内联函数同样按值传递——square(4.5 + 7.5) 先算出 12 再传入,square(c++) 先用 13 平方、c 再加一次 1,这远胜 C 的宏;② 定义放在首次使用之前,本身就充当了原型,所以传 int/long 也能自动转 double。

常见坑:把 C 的 #define 宏当内联用

#define SQUARE(X) X*X文本替换SQUARE(4.5+7.5) 展开成 4.5+7.5*4.5+7.5(结果错),SQUARE(c++) 让 c 自增两次。就算加满括号 ((X)*(X)),c++ 仍会加两次。函数式服务请一律改用 C++ 内联函数。

判断标准很简单:函数体一两行能放下就适合内联,写不下就不适合。

8.2 引用变量

引用(reference)是已定义变量的别名(alias)。声明里的 & 不是取地址符,而是类型名的一部分——int & 意为”指向 int 的引用”:

// firstref.cpp -- 定义和使用引用
#include <iostream>
int main()
{
    using namespace std;
    int rats = 101;
    int & rodents = rats;    // rodents 是引用
    cout << "rats = " << rats;
    cout << ", rodents = " << rodents << endl;
    rodents++;
    cout << "rats = " << rats;
    cout << ", rodents = " << rodents << endl;
    // 某些实现需要把地址转换为 unsigned 再输出
    cout << "rats address = " << &rats;
    cout << ", rodents address = " << &rodents << endl;
    return 0;
}
rats = 101, rodents = 101
rats = 102, rodents = 102
rats address = 0x0065fd48, rodents address = 0x0065fd48

值相同、地址相同——rodents++ 加的是同一个变量的两个名字之一。

引用像”伪装的 const 指针”,但有三个关键差异:

  1. 声明时必须初始化,之后不能改投他主;
  2. int & rodents = rats; 本质上等价于 int * const pr = &rats;,用 rodents 就像用 *pr
  3. 给引用赋值 = 给原变量赋值,不是换绑定。
// secref.cpp 的核心实验
int rats = 101;
int & rodents = rats;    // rodents 绑定 rats
int bunnies = 50;
rodents = bunnies;       // 不是改绑!等价于 rats = bunnies;
// 输出:bunnies = 50, rats = 50, rodents = 50
// rats 和 rodents 地址仍相同,与 bunnies 的地址不同

常见坑:以为 rodents = bunnies; 是让引用改指 bunnies

引用一旦绑定终身不换。这条语句只是把 bunnies 的值赋给了 rats(rodents 只是别名)。想换绑定对象,引用做不到——那是指针的活。

8.3 按引用传递

引用的主战场是函数参数:形参成为调用程序中变量的别名,函数直接操作原件——这就是按引用传递(passing by reference),打破了 C 只有按值传递的传统。


▲ 图 8.2 按值传递与按引用传递

用经典的”交换两个数”对比三种方案:

// swaps.cpp -- 用引用和用指针交换
#include <iostream>
void swapr(int & a, int & b);    // a、b 是 int 的别名
void swapp(int * p, int * q);    // p、q 是 int 的地址
void swapv(int a, int b);        // a、b 是新变量
int main()
{
    using namespace std;
    int wallet1 = 300;
    int wallet2 = 350;
    swapr(wallet1, wallet2);  // 传变量
    swapp(&wallet1, &wallet2); // 传地址
    swapv(wallet1, wallet2);  // 传值(交换失败)
    return 0;
}
 
void swapr(int & a, int & b) // 用引用
{
    int temp;
    temp = a;
    a = b;
    b = temp;
}
 
void swapp(int * p, int * q) // 用指针
{
    int temp;
    temp = *p;
    *p = *q;
    *q = temp;
}
 
void swapv(int a, int b) // 用值(没用)
{
    int temp;
    temp = a;
    a = b;
    b = temp;
}

结果:引用和指针版本都交换成功,按值版本失败(交换的是复印件)。有意思的是 swapr(wallet1, wallet2)swapv(wallet1, wallet2) 调用写法一模一样,只有看原型才知道谁按引用——而指针版本必须写 &wallet1,一眼可辨。

再看重量的另一面——引用会误伤原件

// cubes.cpp -- 常规参数与引用参数
#include <iostream>
double cube(double a);
double refcube(double &ra);
int main ()
{
    using namespace std;
    double x = 3.0;
    cout << cube(x);
    cout << " = cube of " << x << endl;
    cout << refcube(x);
    cout << " = cube of " << x << endl;
    return 0;
}
 
double cube(double a)
{
    a *= a * a;
    return a;
}
 
double refcube(double &ra)
{
    ra *= ra * ra;
    return ra;
}
27 = cube of 3
27 = cube of 27

cube() 按值,x 不变;refcube() 按引用,x 被改成 27。只想”用”不想”改”就加 const:double refcube(const double &ra);

临时变量与 const 引用

实参类型不匹配或不是左值(lvalue,能被地址引用的数据对象:变量、数组元素、结构成员、解引用的指针等)时,若形参是 const 引用,编译器会生成一个临时匿名变量让引用指向它。两种情形:实参类型正确但非左值(如 x+3.0、字面量 7.0),或类型不对但可转换(如把 long edge 传给 const double &)。

为什么只对 const 开绿灯?想象 swapr(a, b) 收到两个 long——若允许生成临时 int 变量,交换发生在临时变量上,a、b 原封不动,函数”假装成功”。所以:想修改实参的函数禁止临时变量;const 引用只是读数据,临时变量无害且让函数更通用(顺便模拟了按值传递的宽容)。

通关标准:

能说出”能用 const 就用 const”的三个理由:防手滑改数据;让函数同时接受 const 和非 const 实参;必要时可用临时变量处理非左值/类型不符的实参。

C++11 还新增了右值引用 double && rref = std::sqrt(36.0);,可绑定字面量、表达式这类右值,主要服务于移动语义(第 18 章)。原来的 & 引用现在叫左值引用。

8.4 引用与结构体和类对象

引用正是为结构体、类这类大块头而生。用法和基本类型一样:void set_pc(free_throws & ft); 修改原结构,void display(const free_throws & ft); 只读。看完整例子(strc_ref.cpp,记录投篮统计):

//strc_ref.cpp -- 使用结构体引用
#include <iostream>
#include <string>
struct free_throws
{
    std::string name;
    int made;
    int attempts;
    float percent;
};
 
void display(const free_throws & ft);
void set_pc(free_throws & ft);
free_throws & accumulate(free_throws & target, const free_throws & source);
 
int main()
{
    // 部分初始化——剩余成员置 0
    free_throws one = {"Ifelsa Branch", 13, 14};
    free_throws two = {"Andor Knott", 10, 16};
    free_throws three = {"Minnie Max", 7, 9};
    free_throws four = {"Whily Looper", 5, 9};
    free_throws five = {"Long Long", 6, 14};
    free_throws team = {"Throwgoods", 0, 0};
    free_throws dup;
 
    set_pc(one);
    display(one);
    accumulate(team, one);
    display(team);
    display(accumulate(team, two));   // 返回值当参数
    accumulate(accumulate(team, three), four);
    dup = accumulate(team, five);     // 返回值用于赋值
    set_pc(four);
    accumulate(dup, five) = four;     // 不推荐的用法
    return 0;
}
 
void display(const free_throws & ft)
{
    using std::cout;
    cout << "Name: " << ft.name << '\n';
    cout << " Made: " << ft.made << '\t';
    cout << "Attempts: " << ft.attempts << '\t';
    cout << "Percent: " << ft.percent << '\n';
}
 
void set_pc(free_throws & ft)
{
    if (ft.attempts != 0)
        ft.percent = 100.0f *float(ft.made)/float(ft.attempts);
    else
        ft.percent = 0;
}
 
free_throws & accumulate(free_throws & target, const free_throws & source)
{
    target.attempts += source.attempts;
    target.made += source.made;
    set_pc(target);
    return target;
}

程序把 team 的投篮数据逐轮累加(13/14 → 23/30 → 35/48 → 41/62),体现了三个要点:

  • 返回引用:返回类型 free_throws & 时,return target; 返回的是 team 原件本身(不是副本),省去整结构复制;display(accumulate(team, two)) 等价于先 accumulate 再 display(team)。
  • 别返回函数内的局部变量:局部变量随函数结束消亡,返回它的引用就是悬空引用——这是本节最大的雷。
  • 返回引用让 accumulate(dup, five) = four; 能编译(返回值是可修改的左值),但它先加后覆盖,纯属自找麻烦。把返回类型改成 const free_throws & 即可封死这种写法。

引用与类对象:ostream 的魔法

类的常用传参方式就是 const 引用。更妙的是继承带来的性质:基类引用可以指向派生类对象,无需类型转换。ostream & 形参既能收 cout(ostream 对象),也能收 fout(ofstream 文件流对象)——一个函数同吃屏幕和文件:

//filefunc.cpp -- 使用 ostream & 参数的函数
#include <iostream>
#include <fstream>
#include <cstdlib>
using namespace std;
 
void file_it(ostream & os, double fo, const double fe[], int n);
const int LIMIT = 5;
int main()
{
    ofstream fout;
    const char * fn = "ep-data.txt";
    fout.open(fn);
    double objective;
    cout << "Enter the focal length of your "
            "telescope objective in mm: ";
    cin >> objective;
    double eps[LIMIT];
    cout << "Enter the focal lengths, in mm, of " << LIMIT
         << " eyepieces:\n";
    for (int i = 0; i < LIMIT; i++)
    {
        cout << "Eyepiece #" << i + 1 << ": ";
        cin >> eps[i];
    }
    file_it(fout, objective, eps, LIMIT); // 写文件
    file_it(cout, objective, eps, LIMIT); // 写屏幕——同一个函数!
    cout << "Done\n";
    return 0;
}
 
void file_it(ostream & os, double fo, const double fe[], int n)
{
    ios_base::fmtflags initial;
    initial = os.setf(ios_base::fixed); // 保存初始格式状态
    os.precision(0);
    os << "Focal length of objective: " << fo << " mm\n";
    os.setf(ios::showpoint);
    os.precision(1);
    os.width(12);
    os << "f.l. eyepiece";
    os.width(15);
    os << "magnification" << endl;
    for (int i = 0; i < n; i++)
    {
        os.width(12);
        os << fe[i];
        os.width(15);
        os << int (fo/fe[i] + 0.5) << endl;
    }
    os.setf(initial); // 恢复初始格式状态
}

输入物镜焦距 1800mm 和目镜焦距 30、19、14、8.8、7.5,得到放大倍数 60、95、129、205、240,同时写入文件和屏幕。附加福利:setf() 返回调用前的格式设置(存进 ios_base::fmtflags),函数结束时还原——每个对象存各的格式,互不干扰。

8.5 何时使用引用

两条理由:修改调用程序中的数据传大对象时省去复制。决策口诀:

  • 函数不改数据:小对象(内置类型/小结构)按值传;数组用指针(唯一选择,加 const);较大的结构用 const 指针或 const 引用;类对象用 const 引用(C++ 的标准姿势)。
  • 函数要改数据:内置类型用指针(fixit(&x) 一眼看出要改 x);数组用指针;结构用引用或指针;类对象用引用。

当然有例外:cin >> n 对基本类型就用引用,否则你得起 cin >> &n 了。

8.6 默认参数

默认参数:调用时省略的实参自动用预设值。必须在原型里从右往左设置(右边没默认的参数不能有默认):

int harpo(int n, int m = 4, int j = 5);  // 合法
int chico(int n, int m = 6, int j);      // 非法!j 在 m 右边却没默认值

实参从左往右依次分配,不能跳着给:harpo(3, ,8) 非法。harpo(2)harpo(2,4,5)harpo(1,8)harpo(1,8,5)

// left.cpp -- 带默认参数的字符串函数
#include <iostream>
const int ArSize = 80;
char * left(const char * str, int n = 1);
int main()
{
    using namespace std;
    char sample[ArSize];
    cout << "Enter a string:\n";
    cin.get(sample,ArSize);
    char *ps = left(sample, 4);
    cout << ps << endl;
    delete [] ps;    // 释放旧字符串
    ps = left(sample);   // n 用默认值 1
    cout << ps << endl;
    delete [] ps;    // 释放新字符串
    return 0;
}
 
// 返回指向新字符串的指针,内容为 str 的前 n 个字符
char *left(const char *str, int n)
{
    if(n < 0)
        n = 0;
    char *p = new char[n+1];
    int i;
    for (i = 0; i < n && str[i]; i++)
        p[i] = str[i];    // 复制字符
    while (i <= n)
        p[i++] = '\0';    // 其余补 '\0'
    return p;
}

输入 forthcoming,输出 fortf。注意默认值只写在原型里,函数定义与平常无异。循环条件 i < n && str[i] 一石二鸟:既限定 n 个字符,又在遇到 \0(字符串太短)时提前收手。

常见坑:在定义里重复写默认值

默认值只能出现在原型中。若定义里再写一次 int n = 1,多数编译器会报错。也别忘了原型里的默认值只此一份,改默认值只需改原型。

8.7 函数重载

函数重载(function overloading,也叫函数多态):多个函数共用一个名字,靠函数签名(argument list,参数的个数、类型和顺序,与变量名和返回类型无关)区分。就像动词 “root” 多义——为球队喝彩、在土里拱松露——语境(实参类型)自动决定用哪个:

void print(const char * str, int width); // #1
void print(double d, int width);         // #2
void print(long l, int width);           // #3
void print(int i, int width);            // #4
void print(const char *str);             // #5
 
print("Pancakes", 15); // 用 #1
print(1999, 12);       // 用 #4
print(1999L, 15);      // 用 #3

三个雷区:

  1. 类型转换会造成二义unsigned year; print(year, 6); 不匹配任何原型,编译器试图标准转换,但 2/3/4 三个都候选——拒绝调用。只留一个原型时转换才能进行。
  2. 类型和它的引用是同一签名double cube(double x)double cube(double & x) 不能共存,cube(x) 匹配不了到底哪个。
  3. 返回类型不算签名long gronk(int, float)double gronk(int, float) 不能重载;签名不同才可以。

const 与非 const 倒是能区分:dribble(char *)dribble(const char *) 是合法重载,编译器按实参是否 const 选择。

C++ 编译器用名称修饰(name decoration/mangling)实现重载——它根据参数类型把名字加密成 ?MyFunctionFoo@@YAXH 这类内部符号,签名不同、符号不同。

重载引用参数(C++11 三件套)

void stove(double & r1);        // 匹配可修改左值
void stove(const double & r2);  // 匹配 const 左值
void stove(double && r3);       // 匹配右值

stove(x) 用第一版、stove(y)(y 为 const)用第二版、stove(x+y) 用第三版——精确匹配者优先。

原书还给出一个完整例子(Listing 8.10,leftover.cpp):字符串版 left(const char *, int) 与整数版 left(unsigned long, unsigned) 共存于同一程序,分别取字符串前 n 个字符和整数前 ct 位数字(用反复除以 10 的技巧数位、砍位)。这正是重载的典型场景:同一任务、不同数据形式

常见坑:滥用重载

只为”基本相同的任务”重载。若只是参数个数不同,用默认参数更省事(一个函数、一份内存、一处修改);参数类型不同时才轮到重载出场。

8.8 函数模板

函数模板(function template)是通用函数描述:用泛型代替具体类型,编译器按实参类型自动生成对应版本——这叫泛型编程,类型成了参数(parameterized types)。手写多个版本费时易错:全局把 int 替换成 double,连 short interval 都会惨变成 short doubleerval

// funtemp.cpp -- 使用函数模板
#include <iostream>
// 函数模板原型
template <typename T> // 或 class T
void Swap(T &a, T &b);
int main()
{
    using namespace std;
    int i = 10;
    int j = 20;
    cout << "i, j = " << i << ", " << j << ".\n";
    cout << "Using compiler-generated int swapper:\n";
    Swap(i,j); // 生成 void Swap(int &, int &)
    cout << "Now i, j = " << i << ", " << j << ".\n";
 
    double x = 24.5;
    double y = 81.7;
    cout << "x, y = " << x << ", " << y << ".\n";
    cout << "Using compiler-generated double swapper:\n";
    Swap(x,y); // 生成 void Swap(double &, double &)
    cout << "Now x, y = " << x << ", " << y << ".\n";
    return 0;
}
 
// 函数模板定义
template <typename T> // 或 class T
void Swap(T &a, T &b)
{
    T temp;    // temp 是 T 类型的变量
    temp = a;
    a = b;
    b = temp;
}
i, j = 10, 20.
Using compiler-generated int swapper:
Now i, j = 20, 10.
x, y = 24.5, 81.7.
Using compiler-generated double swapper:
Now x, y = 81.7, 24.5.

要点:typenameclass 在此语境完全等价(typename 更直白);模板本身不是函数,只是生成函数的”图纸”,编译器把 T 替换成 int、double 各造一份——可执行程序不会因此变短,省的是你的编写时间和出错率。模板通常放头文件里(第 9 章讲头文件)。

重载模板

算法对不同类型不适配时,可以像函数一样重载模板(签名须不同)。Listing 8.12(twotemps.cpp)给 Swap 加了一个交换两个数组的版本:

// twotemps.cpp -- 重载模板函数
template <typename T>    // 原模板
void Swap(T &a, T &b);
template <typename T>    // 新模板
void Swap(T *a, T *b, int n);
...
template <typename T>
void Swap(T a[], T b[], int n)
{
    T temp;
    for (int i = 0; i < n; i++)
    {
        temp = a[i];
        a[i] = b[i];
        b[i] = temp;
    }
}

Swap(i,j) 匹配原模板,Swap(d1,d2,Lim)(两个 int 数组加个数)匹配新模板。

显式具体化

模板有假设(a=b 要求类型支持赋值,a>b 要求支持 >,结构体、数组名都会翻车)。解法之一是显式具体化(explicit specialization):对特定类型给出专门定义。C++98 规矩:

  • 同名函数可同时有:非模板函数、模板函数、显式具体化模板函数;
  • 具体化的原型和定义前加 template <> 并指名类型;
  • 优先级:非模板函数 > 显式具体化 > 模板生成的版本
// twoswap.cpp -- 具体化覆盖模板
struct job { char name[40]; double salary; int floor; };
 
template <typename T>
void Swap(T &a, T &b);                       // 模板
 
template <> void Swap<job>(job &j1, job &j2); // 针对 job 的显式具体化
// <job> 可省:template <> void Swap(job &, job &);
 
// 只交换 job 的 salary 和 floor 字段
template <> void Swap<job>(job &j1, job &j2)
{
    double t1;
    int t2;
    t1 = j1.salary;
    j1.salary = j2.salary;
    j2.salary = t1;
    t2 = j1.floor;
    j1.floor = j2.floor;
    j2.floor = t2;
}

Swap(i,j) 走模板生成 int 版;Swap(sue, sidney)(两个 job)走具体化版——名字不动,工资和楼层对调。

实例化与具体化辨析

  • 隐式实例化:编译器看到 Swap(i,j) 自动生成 int 版;
  • 显式实例化:直接下命令 template void Swap<int>(int, int);(用模板造 int 版);
  • 显式具体化template <> void Swap<job>(job &, job &);不要用模板,用我专门写的定义)。

区分口诀:显式实例化 template没有 <>;显式具体化 template <>。同一文件里对同一类型既显式实例化又显式具体化是错误。Add<double>(x, m) 这种写法还能强制实例化并顺手类型转换——但 Swap<double>(m, x) 不行,因为 double & 引用不了 int 变量。

8.9 模板进阶与 C++11 新工具

重载解析:编译器怎么挑函数

面对重载 + 模板的大杂烩,编译器三阶段决策:①收集候选(同名函数与模板);②筛出可行(参数个数对、存在隐式转换序列);③选出最佳,选不出就报错。转换优劣排序:

  1. 完全匹配(普通函数优先于模板)
  2. 提升转换(char/short→int,float→double)
  3. 标准转换(int→char,long→double)
  4. 用户自定义转换

完全匹配允许表 8.1 的无关紧要转换(trivial conversions):Type→Type &Type &→TypeType []→Type *Type→const TypeType *→const Type * 等。所以 recycle(ink) 对四个原型 recycle(blot)recycle(const blot)recycle(blot &)recycle(const blot &) 都是精确匹配——直接二义报错。两个例外能救场:非 const 数据优先匹配非 const 指针/引用形参;非模板优先于模板。两个模板打平时,更具体(partially ordered,推导时转换更少)者胜——比如 recycle(Type *)recycle(Type) 更具体,recycle(&ink) 会选前者。

Listing 8.14(tempover.cpp)实战了这条规则:ShowArray(things, 6)(int 数组)用模板 A;ShowArray(pd, 3)(double 指针数组)同时匹配 A(T=double *,显示地址)和 B(T=double,显示 *arr[i] 值),B 更具体而胜出,输出 2400 1300 1800 而非三个地址。Listing 8.15(choices.cpp)则展示了人为干预:lesser<>(m, n) 用尖括号强令编译器选模板,lesser<int>(x, y) 强制 int 实例化(double 被截断成 15)。

decltype:这个类型是什么?

模板里的经典难题:?type? xpy = x + y;——T1、T2 相加结果的类型,写代码时无法预知。C++11 的 decltype 按四步清单推断类型:

  1. 未加括号的标识符 → 与该标识符同类型(含 const 等限定符);
  2. 函数调用 → 返回类型(不真正调用,查原型即可);
  3. 左值 → 该类型的引用(decltype((xx)) 因为多了一层括号,得到 double &);
  4. 以上皆非 → 与表达式同类型(decltype(k+n) 虽然是两个引用相加,结果是 int)。

于是:decltype(x + y) xpy = x + y; 一行搞定。

后置返回类型

返回类型也依赖参数时(?type? gt(T1 x, T2 y) { return x + y; }),decltype 帮不上——函数名处 x、y 还没声明。C++11 的后置返回类型(trailing return type)把返回类型挪到参数表后面:

double h(int x, float y);
// 等价于
auto h(int x, float y) -> double;
 
// 模板问题的最终解法
template<class T1, class T2>
auto gt(T1 x, T2 y) -> decltype(x + y)
{
    ...
    return x + y;
}

auto 在这里是占位符,真正的类型由 -> decltype(...) 提供——此时 x、y 已在作用域内。

通关标准:

能独立完成:① 用 const 引用改写第 7 章的结构体函数并解释效率收益;② 写出带默认参数的原型并说明”从右往左”限制;③ 举出两个合法重载与两个非法重载的例子;④ 手写一个 Swap 模板并为自定义结构写出显式具体化,说清编译器选择函数的优先顺序。