第三章 函数

本文介绍了C++编程中关于函数的一些关键概念,包括为什么不应返回局部对象的引用,如何利用C++11的列表初始化返回值,以及main函数的返回值规范。此外,讨论了内联函数的优势与限制,如减少调用开销但可能导致代码膨胀,以及内联函数在头文件中的定义。函数重载的原则和注意事项,如不能基于返回类型重载,以及const形参的影响。最后,探讨了函数指针的使用,包括如何声明和调用,以及在参数和返回值中的应用。
摘要由CSDN通过智能技术生成

3.3 函数返回类型

不要返回局部对象的引用或指针

函数完成后它所占用的存储空间也会被释放掉,因此局部变量的引用将指向不再有效的内存区域:

// 严重错误: 试图返回局部对象的引用
const string &foo() {
    string ret;
    if (!ret.empty()) {
        return ret;      // 错误: 返回局部对象的引用
    } else {
        return "Empty";  // 错误: 返回局部对象的引用
    }
}

列表初始化返回值

C++11新标准规定,函数可以通过列表初始化来对函数返回的临时量进行初始化:

#include <string>
#include <vector>

std::vector<std::string> foo(int i) {
    if (i < 5) {
        return {};  // 返回一个空vector对象
    }
    return {"tomo", "cat", "tomocat"};  // 返回列表初始化的vector对象
}

int main() {
    foo(10);
}

main函数返回值

main函数的返回值可以看成是状态指示器,返回0表示成功,返回其他值表示失败。cstdlib头文件定义了两个预处理变量,分别表示成功和失败:

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

返回函数指针

由于数组不能拷贝,因此函数不能返回数组,不过可以返回数组的指针或者引用。想要定义一个返回数组的引用或者指针的函数比较繁琐,不过我们可以使用类型别名来简化这一任务:

// arrT: 包含10个整型元素数组的类型别名
typedef int arrT[10];
// arrT的等价声明
using arrT = int[10];

arrT* func(int i);  // 返回指向10个整数的数组的指针

如果不使用类型别名,那么相同的函数我们需要写成:

int (*func(int i))[10];

C++11允许我们使用尾置返回类型:

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

还有一种情况是如果我们直到函数返回的指针将指向哪个数组,就可以使用decltype关键字声明返回类型:

int odd[] = {1, 3, 5, 7, 9};
int even[] = {0, 2, 4, 6, 8};

// 根据i指向的不同返回两个已知数组中的一个
decltype(odd) *arrPtr(int i) {
    return (i % 2) ? &odd : &even;
}

尾置返回类型

编码规范:只有在常规写法(返回类型前置)不便于书写或者不便于阅读时才使用返回类型后置语法。

C++现在允许两种不同的函数声明方式,以往的写法是将返回类型置于函数名之前:

int foo(int x);

C++11新标准引入了尾置返回类型,可以在函数名前使用auto关键字,在参数列表之后后置返回类型,例如:

Tips:尾置返回类型是显式地指定Lambda表达式返回值的唯一方式,当返回类型依赖模板参数时也可以使用使用尾置返回类型。

// 普通函数
auto foo(int x) -> int;

// lambda表达式
auto f = []() -> int { return 42; };

// 模型函数
template <class T, class U> auto add(T t, U u) -> decltype(t + u);

3.4 内联函数

函数的劣势

调用函数一般比求等价表达式的值要慢一些,在大多数机器上一次函数调用其实包含着一系列工作:

  • 调用前要先保存寄存器,并在返回时恢复
  • 可能需要拷贝实参
  • 程序转向一个新的位置继续执行

简介

Tips:内联机制用于优化规模较小、流程直接、频繁调用的函数。很多编译器都不支持内联递归函数,而且一个75行的函数也不大可能在调用点内联地展开。

使用内联函数可以避免函数调用的开销,它会在每个调用点上“内联地”展开。

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

定义在头文件中

和其他函数不同,内联函数可以在程序中多次定义。毕竟编译器想要展开函数仅有函数声明时不够的,还需要函数的定义。由于对于某个给定的内联函数来说,它的多个定义必须完全一致,基于这个原因内联函数通常定义在头文件中。

定义在类内部的函数是隐式的内联函数

Tips:和我们在头文件中定义inline函数的原因一样,inline成员函数也应该与相应的类定义在同一个头文件中。

在类中常有一些规模较小的函数适合于被声明成内联函数,其中定义在类内部的成员函数和友元函数是自动inline的,在类的外部我们可以用inline关键字修饰函数定义将其显式声明为内联函数。

声明为内联的函数也不一定会被编译器内联

有些函数即使被声明为内联的也不一定会被编译器内联,比如虚函数和递归函数就不会被正常内联:

  • 递归层数在编译时可能是未知的,因此大多数编译器都不支持内联递归函数
  • 用类指针调用虚函数时不会被内联展开,因为此时编译器还不知道运行时哪个函数会被调用

编码规范:彻底了解inlining的里里外外

Effective C++:Understand the ins and outs inlining.

  • 将大多数inlining 限制在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级(binary ungradability)更容易,也可以使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。
  • 不要只因为function templates出现在头文件,就将它们声明为inline。
1. inline函数的优缺点

inline函数可以使你调用它们且不需要蒙受函数调用所导致的额外开销。另外编译器最优化机制通常被设计用来浓缩那些“不含函数调用”的代码,所以当你inline某个函数,或许编译器就因此有能力对它(函数本体)执行语境相关最优化。大部分编译器绝不会对着一个“outlined函数调用”动作执行这样的优化。

inline函数背后的逻辑是将“对此函数的每一个调用”都以函数本体替换之,这样做可能增加你的目标码(object code)大小。在一台内存有限的机器上,过度热衷inlining会造成程序体积太大(对可用空间而言)。即使拥有虚内存,inline造成的代码膨胀也会导致额外的换页行为(paging),降低指令高速缓存装置的击中率(instruction cache hit rate),以及这些伴随而来的效率损失。

换个角度说,如果inline函数本体很小,编译器针对“函数本体”产出的码可能比针对“函数调用”所产出的码更小。这样将函数inlining确实可能导致较小的目标码(object code)和较高的指令高速缓存装置击中率。

另外inline函数无法随着程序库的升级而升级,假设f()是程序库内的一个inline函数,客户端将其函数本体编进其程序中。一旦程序库设计者决定修改f()的实现,所有用到该函数的客户端程序都必须重新编译。但是如果f()是non-inline函数,一旦它有任何修改,客户端只需重新链接即可,远比重新编译的成本少得多。如果程序库采取动态链接,升级版函数甚至可以不知不觉地被应用程序吸纳。

2. inline常处于头文件中

inline函数通常一定被置于头文件内,因为大多数C++程序在编译期间进行inlining,而为了将一个“函数调用”替换成“被调用函数的本体”,编译器必须知道那个函数长什么样子。

3. template与inline

template通常也被置于头文件内,因为它一旦被使用,编译器为了将它具现化,需要知道它长什么样子。

template的具现化与inlining无关。如果你正在写一个template且希望此template具现出来的函数都应该inlined,请将此template声明为inline。但如果你写的template没理由要求它所具现化的每一个函数都是inlined,就应该避免将这个template声明为inline(无论显式还是隐式),否则可能会导致代码膨胀。

4.virtual函数与inline

一个表面上看似inline的函数未必真的是inline函数,这取决于编译器。

大部分编译器拒绝将太过复杂(例如带有循环或递归)的函数inlining,而所有对virtual函数的调用(除非是最平淡无奇的)也都会使inlining落空。因为virtual意味着直到运行期才直到调用哪个函数,而inline意味着执行前先将调用动作替换为被调用函数的本体。如果编译器不知道该调用哪个函数,你就很难责备它们拒绝它们将函数本体inlining。

5. inline函数也可能生成outlined函数本体

有时候虽然编译器有意愿inlining某个函数,还是可能为该函数生成一个函数本体。

举个例子,如果程序要取得某个inline函数的地址,编译器通常必须为此函数生成一个outlined函数本体。毕竟编译器没法生成一个指针指向并不存在的函数。编译器通常不对“通过函数指针而进行的调用”实施inlining,这意味对inline函数的调用可能被inlined,也可能不被inlined,取决于调用的实施方式:

inline void f() {...}  // 假设编译器有意愿inline对f的调用
viud (*pf)() = f;      // pf指向f

f();   // 这个调用将被inlined, 因为它是一个正常调用
pf();  // 这个调用或许不被inlined, 因为它通过函数指针达成

有时候编译器会生成构造函数和析构函数的outline副本,如此一来它们就可以获得指针指向那些函数,在array内部元素的构造和析构过程中使用。

实际上构造函数和析构函数往往是inlining的糟糕候选人,以下面的Derived类为例:

class Base {
 public:
    ...
 private:
    std::string bm1, bm2;
};

class Derived : public Base {
 public:
	Derived() {}  // Derived构造函数看似是空的, 然而事实如此吗?
    ...
 private:
    std::string dm1, dm2, dm3;
};

C++对于“对象被创建和销毁时发生什么事”做了各式各样的保证,例如如果有个异常在对象构造期间被抛出,该对象已构造好的那一部分会被自动销毁 。编译器为表面上为空的Derived构造函数产生的代码相当于:

// 空白Derived构造函数的观念性实现
Derived::Derived() {
    // 初始化Base成分
    Base::Base();
    
    // 试图构造dm1, 如果抛出异常销毁Base成员并抛出异常
    try { dm1.std::string::string(); }
    catch (...) {
        Base::~Base();
        throw;
    }
    
    // 试图构造dm2, 如果抛出异常就销毁dm1和base部分, 并抛出异常
    try { dm2.std::string::string(); }
    catch (...) {
        dm1.std::string::~string();
        Base::~Base();
        throw;
    }
    
    // 试图构造dm3, 如果抛出异常就销毁dm2、dm1和base部分, 并抛出异常
    try { dm3.std::string::string(); }
    catch (...) {
        dm2.std::string::~string();
        dm1.std::string::~string();
        Base::~Base();
        throw;
    }
}

上面这段代码并不能代表编译器真正制造出来的代码,因为真正的编译器会以更加复杂的做法来处理异常。无论编译器在其内部所做的异常处理多么精致复杂,Derived构造函数至少一定会陆续调用其成员变量和base class两者的构造函数,而那些调用会影响编译器是否对此空白函数inlining。

3.5 函数重载

简介

Tips:不允许两个函数除了返回类型外其他所有的要素都相同,即不能基于返回类型的重载。

如果同一作用域内的几个函数名字相同但是形参列表不同,我们称之为重载函数。对于重载的函数,它们应该在形参数量或者形参类型上有所不同。

重载与const形参

1. 顶层const

Tips:一个拥有顶层const形参的函数无法和另一个没有顶层const形参的函数区分开。

// 错误: 重复声明foo(string)函数
void foo(string);
void foo(const string);

// 错误: 重复声明foo(string*)函数
void bar(string*);
void bar(string* const);
2. 底层const

Tips:const引用形参的函数可以和非const引用形参的函数区分开。

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

// 正确
void foo(string&);
void foo(const string&);

// 正确
void bar(string*);
void bar(const string*);

const_cast与重载

Tips:const_cast最常用于重载函数的情景。

// 常量引用的函数版本
const string &shorterString(const string &s1, const string &s2) {
    return s1.size() <= s2.size() ? s1 : s2;
}

// 非常量引用的函数版本复用常量引用的函数版本
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);
}

调用重载函数

调用重载函数时有三种可能的结果:

  • 编译器找到一个与实参最佳匹配的函数,并生成调用该函数的代码
  • 找不到任何一个函数与调用的实参匹配,这时候编译器发出无匹配的错误信息
  • 有多于一个函数可以匹配,但是每一个都不是明显的最佳选择,此时会发生二义性调用的错误

3.6 函数指针

简介

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

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

// pf是一个未初始化的函数指针: 参数是两个const string的引用, 返回值是bool类型
bool (*pf) (const string &, const string &);

把函数名作为一个值时, 该函数自动转换成指针:

// pf指向名为lengthCompare的函数
pf = lengthCompare;
// 等价写法
pf = &lengthCompare;

我们可以使用函数指针调用该函数:

// 等价的三种写法
bool b1 = pf("tomo", "cat");
bool b2 = (*pf)("tomo", "cat");
bool b3 = lengthCompare("tomo", "cat");

函数指针形参

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

// 第三个参数是函数类型, 它会自动转换成函数指针
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 &));

我们可以使用类型别名和decltype来简化使用了函数指针的代码:

Tips:decltype返回函数类型,此时不会将函数类型自动转换成指针类型,只有在结果前面加上*才能得到函数指针。

// Func1和Func2是函数类型
typedef bool Func1(const string&, const string&);
typedef decltype(lengthCompare) Func2;

// FuncP1和FuncP2是函数指针类型
typedef bool(*FuncP1)(const string&, const string&);
typedef decltype(lengthCompare) *FuncP2;

// 使用类型别名简化useBigger函数的声明
void useBigger(const string&, const string&, Func1);
void useBigger(const string&, const string&, Func2);
void useBigger(const string&, const string&, FuncP1);
void useBigger(const string&, const string&, FuncP2);

返回指向函数的指针

1. 类型别名using简化返回函数指针的函数声明

一般情况下直接声明返回函数指针的函数比较复杂:

// foo参数为int, 返回值是int(*)(int*, int)的函数指针
int (*foo(int))(int*, int);

新标准下我们可以使用using关键字定义类型别名:

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

有了类型别名我们可以将foo函数重新声明为:

// foo接收int类型作为参数, 返回PF的函数指针
PF foo(int);
// 等价写法
F *foo(int);
2. 尾置返回类型

前面提到的foo函数还有另外一种声明方式:

auto foo(int) -> int(*)(int* int);
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值