What & How & Why

表达式

第 6 章笔记


函数基础

封装了代码,用于反复利用

  • 函数头
    • 需要是一个合法的标识符
    • 形式参数:函数输入的参数(parameter)
    • 返回类型:函数执行完成后得到的结果
  • 函数体:语句(必须带大括号),包含具体的计算逻辑

函数的声明和定义

  • 声明:只有函数头,没有函数体
  • 定义:两者都有
  • 声明可出现多次,定义只能出现一次(编译器无法选择哪段对应的汇编代码)
  • 编译执行顺序为从头到尾,因此需要函数的前置声明(告诉编译器该函数存在,在最后引入函数的定义)
  • 通常将声明加入 header 中,使用时包含即可
  • 只有声明没有定义会出现链接错误,这是因为链接期会寻找函数的定义。

函数定义出现多次的例外:inline

函数的调用

  • 需要函数名与实际参数(argument)
  • 实际参数会通过拷贝初始化交给形式参数进行参数传递
  • 返回值会通过拷贝初始化进行返回
  • 拷贝过程可能会被省略(涉及到优化及C++的新feature)
Stack Frame

调用函数时,函数内部的变量(局部变量)是通过 stack frame 的结构进行组织的。当牵涉到多重调用时,越往后调用的函数越处于 stack 的上方。

int Add(int x, int y)
{....}
int main()
{
    int x = 1;
    int y = 2;
    // Add 在 stack 中处于 main() 的上方
    // 从下到上,内存地址是逐渐减小的
    Add(1,2); 
}

  • 保存顺序:实参→返回地址→本地变量

函数的外部链接

以下面的程序为例:

int Add(int x, int y)
{
    return x + y;
}

int Sub(int x, int y)
{
    return x - y;
}

int main(int argc, char const *argv[])
{
    /* code */
    return 0;
}
# list all external link of demo
nm demo
# 需要关注的内容
# main
0000000140001476 T main
# add & sub
# 注意是 mangling 的名称
# 编译器友好形式
0000000140001450 T _Z3Addii
0000000140001464 T _Z3Subii

#demangle
nm demo | c++filt -t

# 结果,链接中实际保存了参数的信息
# 阅读友好形式
0000000140001450 T Add(int, int)
0000000140001464 T Sub(int, int)

  • 函数需要函数名和参数信息(参数的数量和类型)来确定,主要用于函数的重载。
  • C 不支持该类信息。如果需要支持 C 的调用,需要声明 extern “C”,请求编译器将该函数声明为 C 友好的外部链接形式
    • 声明后该函数唯一,不再支持重载。

函数详解

参数

  • 函数的形参可以是 0 到多个,参数列表为 0 时可以用 void 代替
  • 函数形参可以没有参数名称,但必须要有确定的参数类型
    • 函数类型用于编译器检查
    • 名称用于传入数值
  • 形参的变化不会引入新的函数
    • 编译器只检查函数类型
  • 初始化的顺序与形参的顺序无关
  • C++ 17 会强制省略复制临时对象

// 0参数 等价形式
void fun(){};
void fun(void){};

// 无参数名称的函数
void fun(int, int y) {}

// 名称不影响函数在编译器内的匹配
// redefination
void fun(int, int y) {};
void fuin(int a, int b) {};

// C++ 17 强制省略复制临时对象
struct Str
{
    Str() = default;
    Str(const Str&) {};
}
void fun(Str) {};
int main() 
{
    // 拷贝构造
    Str a;
    fun(a);
    // 临时对象不会拷贝构造
    fun(Str{});
}

值传递 & 地址(引用)传递

都是拷贝初始化的过程,但是行为不同:

  • 值传递:将值复制后进行传递
  • 地址(指针)传递:解引用→找到内存地址→修改内存内容
  • 引用传递:引用绑定了内存地址→修改内存内容
参数传递过程中会发生退化
  • 数组传递中,参数会由数组类型退化为指向数组首地址的指针类型
  • 数组长度无法传递
  • 多维数组的最高位才会产生退化

// 注意这里的函数参数
// int (*)[] 指参数类型是指向元素为一维数组的数组指针
void func2(int (*par)[]) { }
// 等价写法
// 注意 [4],元素数组的长度必须与 argument 匹配
void func2(int [][4]) {}
int main()
{
    int a[3][4];
    func2(a);
    return 0;
}

  • 如果需要阻止类型退化,以引用的形式作为参数

// 注意引用和指针的区别
// 此时不会退化,因此以二维数组的作为实参的形参必须是二维数组的引用
// 该方法可以传递数组长度
void func4(int (&ref)[3][4]) {}
int main()
{
    int a[3][4];
    func4(a);
    // error: invalid initialization of reference of type
    int b[2][4];
    func4(b);
}

initializer List
  • 需要使用 <initializer_list>
  • 所有参数必须类型相同

init list 维护了两个指针。其拷贝只牵涉到了指针的拷贝,因此不需要特地传引用。

函数的缺省参数

// 自定义缺省参数
void fun(int x = 0) {};
// 调用 fun(0)
fun();

  • 多参数情况下,若某个参数有缺省参数,其右边的所有参数都必须有缺省参数
  • 同个翻译单元中,缺省参数只能定义一次

// 声明中可以进行缺省参数默认值的定义
// 但同单元只能定义一次

void fun(int x, int y = 2, int z = 3);

//error: previous specification 
void fun(int x, int y = 2, int z = 3)
{
    std::cout << x + y + z << std::endl;
}

int main()
{
    fun(1);
    return 0;
}

  • 如果函数存在多个声明,只要缺省参数的定义不重复,可以组合到一起使用

// ok
void fun(int x, int y = 2, int z = 3);
void fun(int x = 1, int y, int z);

void fun(int x, int y, int z)
{
    std::cout << x + y + z << std::endl;
}

int main(int argc, char const *argv[])
{
    fun();
    return 0;
}

这种情况主要适用于多个翻译单元同时存在的情况下:编译器会根据不同的翻译单元在编译期将不同的默认参数赋予当前的翻译单元。存在这种机制主要是为了允许我们对默认参数的分级控制。比如存在如下三个文件:

  • header.h 定义了 z 的初始值
  • source 定义了 y 的初始值
  • main 定义了 x 的初始值

假设 Header 被 source 和 main 都引用,那么:

  • z 在 header 中被改动时,source / main 中的 z 都会被改变
  • 然而,source 和 main 中的默认参数是独立的,改变 source 中的 y 并不会影响 main 中的 y;同理,main 中的 x 的改变不会影响 source 中的 x。

通常,缺省值不太被改动的参数,都会放到函数的最后面。此类默认缺省值的参数设定一般都至于比较靠近根部的翻译单元中(header)

  • 缺省参数为变量时,编译器会将默认参数视作该变量

int x = 3;
void fun(int y = x){}

// 调用 fun(x),而不是fun(3)
fun();

main() 的带参数版本

int main(int argc, char const *argv[])
{
    for(int i =0; i < argc; ++i)
    {
        std::cout << argv[i] << "\n";
    }
    return 0;
}

# args 
# 1 为 argc = 0; 存储于 argv[0]
# ...
# 4 存储于 argv[3]
demo 1 2 3 4

  • argv[0] 一定是指当前程序名,可以用其表示当前程序名

std::cerr << "Usage: " << argv[0] << "detals ....";

函数体

隐式返回与显式返回
  • void 函数通常是隐式返回
  • main() 也支持隐式返回,返回类型为 int,正常结束的程序返回值为 0
  • 有返回类型的函数通常需要显式返回
    • 使用 return + 语句 / 表达式 / 初始化列表

// void 的显式返回
// 可以用作跳出当前函数体的手段
// 如果有返回值类型,那么返回值必须匹配该类型
return;

返回初始化列表

std::vector<int> fun2()
{
    return {1,2,3,4,5};
}

返回自动对象,指针的注意事项
  • 自动对象(临时,局部变量)被返回时一定要注意其生存周期
  • 不要返回局部变量的引用和指针
    • 静态局部变量的生存周期不受函数影响,因此可以使用
返回值优化
  • 分为具名和非具名返回值
  • C++ 17 会进行强制性的临时对象(非具名)返回优化:返回临时对象不会调用拷贝构造函数

# 初始化和返回默认情况下都会进行拷贝构造
# 系统会开辟内存->拷贝到内存->初始化
# 将拷贝的行为替换为直接在申请内存上直接构造,省略拷贝的过程
# 关闭返回值优化
# not work on C++ 17
fno-elide-constructors

函数的返回类型

  • 返回类型在编译期会确定,表示函数的计算结果类型
  • 返回类型不能省略
函数返回的方式

// 经典返回
int fun(int a, int b) {}
// 尾部返回 C++11
// 使用泛型编程的时候,参数的类型由模板参数决定,在后面写返回类型更方便
// 返回类的成员函数的自定义类型,在后面写更方便
// 尾部返回可以自动查找域
auto fun(int a, int b) -> int {}

// 自动推导返回 C++14
// 基于 return 语句进行推导
// 返回 int
auto fun(int a, int b) { return a + b; }
如果存在多个 return 语句,那么返回结果的类型必须一致,否则需要使用 constexpr if 来处理:
// 与运行期条件不同,constexpr 在编译器生效
// 通过 constexpr value 来舍弃某个分支的 return
// 实际上编译结果只存在一个 return 的类型
constexpr bool value = true;
auto fun()
{
    if constexpr (value) 
    { 
        return 1; 
    } 
    else 
    {
        return 3.14; 
    }
}

返回类型与结构化绑定

struct Str
{
    int x;
    int y;
};

Str fun()
{
    return Str{};
}

int main(int argc, char const *argv[])
{

    // C++ 17
    // v1 v2 实际上是引用
    // 使用 v1, v2 直接绑定了 x 和 y
    auto [v1, v2] = fun();
    v1;
    v2;

    //Str res = fun();
    return 0;
}

nodiscard 属性
  • 防止函数的返回结果没有被正确的利用
  • 防止内存泄漏

// 使用 warning 提醒该返回值非常重要,不能忽略
[[nodiscard]] int fun(int a, int b) 
{
    return a + b;
}

int main()
{
    // 返回值没有用到
    // warning: ignoring return value
    fun(2, 3);
}

函数重载和解析

函数的重载

  • 使用相同函数名定义多个不同参数列表的函数
    • 参数数量不同
    • 参数类型不同
    • 跟参数名和返回类型无关
函数重载和 name mangling

重载函数会被 mangle 为以下的格式:

# 除了函数名,还有参数的信息
000000014000145c T _Z3fund
0000000140001450 T _Z3funi

name lookup

  • 限定查找(只会在指定区域内查找)
  • 非限定查找
    • 优先查找所在域,没找到回去上级
    • 查找顺序从上到下
    • 如果希望优先本域的函数,需要做前置声明
      • 两个同名函数在同域时,在调用重载函数的位置之前声明

void fun()
{
    std::cout << "global fun\n";
}

namespace myNS
{
    void fun()
    {
        std::cout << "NS fun\n";
    }
    void g() 
    {
        fun();
    }
}

int main(int argc, char const *argv[])
{
    // 限定名称查找
    // 指定了查找的区域
    // 域可以由 Namespace,class(sub class) 形成
    ::fun();
    myNS::fun();

    // 非限定名称查找
    // 会从当前的域(myNS)开始查找
    // 一旦查找到名称,就会停止 name lookup
    // 调用 myNS::fun()
    myNS::g();

    // 如果当前域不存在 fun() 则会去上级查找
    // 查找的顺序从上到下,如果域内的 myNS::fun() 的定义在 g() 之后
    // 那么会调用 ::fun()
    // 可以提前声明 myNS::fun() 来告诉编译器本域中存在 myNS::fun() 的定义
    return 0;
}

名称隐藏
  • name lookup 只按照名字来,不区分实体(比如变量与函数同名时,优先选择变量)
  • 也是逐级往上查找
argument dependent lookup
  • 如果是结构体的对象作为参数,结构体的内部会被纳入域
  • 只对自定义类型生效
  • 函数模板会进行实例化,实例化的过程中会导致查找范围扩大

重载解析

  1. 过滤不能被调用的版本(non-viable candidate)
    1. 参数数量不匹配
    2. 实参无法被转变为形参
    3. 实参不满足形参的限制条件
  2. Best match
    1. 单个参数
      1. 选择匹配等级高的版本
匹配等级

级别越低,匹配程度越高

  • rank 1:完美匹配或平凡转换(加一个 const)
  • rank 2:提升(小尺寸到大尺寸)或者提升加平凡转换
  • rank 3:标准转换 / 标准转换加平凡转换
  • rank 4:自定义转换:类的类型转换
  • rank 5:调用形参为省略号的版本
涉及 low const 的情况

// 左值会优先选择非 const 版本
// 编译器会倾向选择保证对应变量的访问权限的函数
void fun(int& x){}
void fun(const int& x){}

// int& 
fun(x);
// const int&
fun(3);

多个形参的情况
  • 多个参数时,最佳匹配函数中的所有参数匹配都优先级都要高于其他候选

其他内容

递归函数
  • 通常用于描述复杂的迭代过程

内联函数

  • 普通函数会有额外的开销
    • 运行期会建立 stack frame 用于保护函数的掉用
  • 内联函数是一种优化机制:如果函数的内部逻辑较为简单,则将函数的内容直接在 main 中执行
  • 编译器替换的机制:
    • 取决于编译器的实现机制
    • 对于逻辑复杂的函数,overhead 的 cost 不是很明显
  • 展开不是简单的替换
    • 替换需要保证替换前和替换后执行效果是一样的,需要检测很多问题(比如命名冲突)
  • 定义在翻译单元外的函数无法内联:内联发生在编译期,而翻译单元以外的定义处理在链接期
内联函数可以放到头文件中
  • inline 关键字修饰的函数可以重复定义。
  • 实际上,inline 函数保证同时只有一个定义用于链接期。这使得函数定义的范围从翻译单元变为了跨翻译单元。
  • inline 的定义应该与其声明处于同一位置

constexpr 函数

  • constexpr 函数在编译期和运行期均可执行
  • constexpr 函数不能调用 non-constexpr 函数(const 的一致性)

// 在编译期和运行期均可调用
constexpr int fun(int x ){ return x};
// 编译器调用
constexpr int x = fun(3);
// 运行期调用
// 由于参数只能在运行期确定,因此是运行期调用
int y;
std::cin >> y;
fun(y);

consteval 函数

  • C++20 的新内容
  • 该函数只能在编译期求值(确保优化)
  • 避免在运行期的误调用
  • inline 的出发点是让函数内容在 main 中的调用处展开,因此兼容性最强
  • constexpr / consteval 更强调在编译期计算

函数指针

  • 函数类型:返回类型+参数类型,比如:int(int)
  • 只能为函数引入声明,不能直接定义
  • 其他使用场景:std::function 可以接受函数类型的参数
函数类型的定义

u,sing K = int(int);
// fun 被声明为 int(int) 类型的函数
K fun;
// 不能通过赋值的方式给出定义
K fun = { return 0; } // error

函数指针

#include <iostream>
#include <vector>
#include <algorithm>

using K = int(int);

int inc(int x)
{
    return x + 1;
}

// 将指向 inc 的函数指针作为参数
// 类型为 K*,也就是 int(*)(int)
// 好处:高阶函数逻辑不变,通过调用的函数来实现不同的功能
// 实例:泛型算法,很多泛型算法接收函数指针作为参数
int twiceInc(K* inc, int x)
{
    int temp = (*inc)(x);
    return temp * 2;
}


int main(int argc, char const *argv[])
{
    // 不是函数声明,是指针
    // 可接收 int(int) 类型的函数
    K* fun = &inc;

    // 通过指针调用函数
    // 解引用->再调用
    std::cout <<(*fun)(100) << std :: endl;
    std::cout << twiceInc(fun, 100) << std::endl;

    // 泛型算法实例
    std::vector<int> a{1,2,3,4,5};
    std::transform(a.begin(), a.end(), a.begin(), fun);

}

函数的退化
  • 赋值时,函数类型会退化为函数指针类型

// fun 是 int(*)(int)
 auto fun = inc;

函数指针与重载

  • 重载时尽量显式的使用函数类型

void fun(int) { std::cout << "single para;\n"; }
void fun(int, int) {std::cout << "double para;\n"; }

int main(int argc, char const *argv[])
{
    // 无法通过 auto 来推断 fun 的类型
    // fun 在此处包含了两个不同类型的函数
    // auto 无法确定选择哪一个 fun
    auto x = fun;

    // 需要显式的指定类型
    using K = void(int);
    K* x = fun; //int(int) type
    return 0;
}

函数指针作为返回值

  • 可以使用函数作为返回值
  • 函数无法复制,返回的类型是函数指针

int inc(int x) { return x + 1; }
int dec(int x) { return x - 1; }

auto fun(bool condition, int x)
{
    // 返回的实际上是函数的指针
    return condition ? inc(x) : dec(x);
}

int main(int argc, char const *argv[])
{

    std::cout << fun(true,1) << std::endl;
    return 0;
}