NJUのC++课:函数

本篇文档是NJU C++课程的第三篇文章,主要讲的是函数这一C++编程中的关键问题。 整理这些内容真的很费力……受不了了😴

函数基础

函数的内存分区

程序内存一般被分为四个主要的区域,下面的内容也围绕着这个部分而展开

在编译时会产生符号表,这仅存在于编译器的内存中,相当于一个花名册,记录了所有的函数、变量的标识符以及其类型,地址,作用域

  • 代码区(Code):存放函数体的二进制指令,是只读的。
  • 数据区 (Data): 存放全局变量和静态变量(如 int x = 0;),可读可写。
  • 堆区 (Heap): 用于动态内存分配(如 malloc),需要程序员手动管理,可读可写。
  • 栈区 (Stack): 用于存放函数的参数、局部变量。每次函数调用都会在栈上自动创建一个新的“栈帧”,函数执行结束后会**自动销毁,**可读可写。

三大核心问题

  • Q1 (成本/COST): 函数调用是有开销的(如压栈、跳转等),如何优化?这指向了后续讲解的内联函数等技术。
  • Q2 (库/Lib): 使用库函数时需要注意什么?这涉及到链接、符号表等概念。见后面的函数重载以及最后的C++组织方式的内容
  • Q3 (执行性): 代码区为什么是只读的?这关系到程序的安全性,防止代码被意外或恶意篡改。ROP攻击:
    • 这种攻击需要在内存中写入新的可执行代码,而是巧妙地利用程序自身代码区里已有的、零碎的、以 ret 指令结尾的代码片段(gadgets),通过在栈上精心布局这些片段的地址,像放多米诺骨牌一样,将它们串联起来,组合成完整的恶意功能。

底层的传递问题

  • 栈帧操作:
    • 首先栈指针移动,为了栈对齐或者为了局部变量分配空间
    • 之后参数压栈。
    • 之后使用CALL指令跳转到函数代码,RET指令返回调用点,栈指针恢复,释放函数所用的栈空间。
  • 参数传递机制
    • 按值传递:将实参的值复制一份传递给函数的实参
    • 按引用传递:将形参成为实参的一个别名,他们指向同一块内存地址

函数和栈变化(C语言形式cdecl)

按值传递

PPT中使用的例子是一个简单的函数int r = func(int a, int b) {int r = a + b; return c;} 我们尝试理解一下其中的栈空间变化

  • 调用准备

    • 在main函数的栈帧中,将栈顶指针esp向下移动8个字节,这里是为什么呢?——栈对齐
    • 参数压栈:首先push $0x2,然后push $0x1
      • 注意这里面参数是从右向左push入栈
    • 发起调用call _Z4funcii这里做了两件事情:
      • 首先将返回地址压入栈顶,这个地址是cal指令的下一条指令的地址,告诉函数执行完应该回到哪里
      • 之后跳转到_Z4funcii函数的入口地址,开始执行函数代码
  • 函数执行

    • 建立新的栈帧:push ebp 和 mov esp, ebp
      • 保存调用者main函数的栈底指针,以便恢复main栈帧
      • 将当前的栈顶设置为新函数的栈底ebp,建立func自己的新栈帧
    • 分配空间:sub $0x10, esp
      • 为func函数自己的局部变量r分配空间
    • 执行函数体:
      • 从栈中获取参数,相对于ebp,ebp+8、ebp+12 分别是第一个和第二个参数,分别加载到寄存器中
      • 执行加法指令
    • 准备返回值:
      • 将寄存器的结果存入func为局部变量r分配的栈空间中
  • 返回和清理:

    • 销毁func栈帧
      • 两个动作,一方面它释放了 func 的局部变量空间,并恢复了调用者 (main) 的栈底指针 ebp(pop ebp)。栈状态“回滚”到了调用 func 之前的样子。
    • 返回:
      • 执行ret,实质上是pop eip,函数回到了main函数继续执行
    • 清理参数栈
      • esp上移,彻底清除之前压入栈的1和2
    • 获取结果
      • 将存入寄存器中的值读取到局部变量中

按引用传递

这一部分我也会详细的说明一下,例子是这个:

  • 调用准备阶段
    • 依旧是为局部变量分配空间,地址作为参数压栈,并发起调用
    • 这里的不同点是使用寄存器存储地址参数将寄存器压入栈中
  • 函数执行阶段
    • 依旧是建立新的栈帧,执行函数体
    • 这里面重点是执行函数体(远程修改)
      • 首先依旧是从栈中获取参数,并将1写入寄存器所存地址对应的r中去
  • 返回结果
    • 依旧是一样的

其余调用约定

在PPT还演示了其他调用约定的相关实例,这里只进行总结

stdcall调用

这里我们用值传递作为例子:int r = func(int a, int b) {int r = a + b; return c;}

  • 关键的不同在于我们让被调用者负责退还栈,在ret的时候直接退还栈空间

使用stdcal会导致你无法传递可变的参数了,而对于cdecl调用来说,这些并不是问题,调用者是知道自己调用了多少参数的,所以他不会产生任何问题

fastcall调用

这里我们用值传递作为例子:int r = func(int a, int b) {int r = a + b; return c;}

  • 关键的不同在于在这里我们将前几个参数(通常是两个)放入寄存器而不是栈中

使用fastcall会比stdcal更快,剩下的参数压栈,压栈的参数由被调用者负责清理

thiscall调用

在PPT中没有展示,仅作了解即可

C++ 成员函数的专属约定

  • 规则: 专门用于传递 C++ 类的 this 指针,通常会把它放在 ECX 寄存器中。

C++对函数的增强

普通增强

  1. 函数原型 (Function Prototype)——这一部分和C差不多
    • 核心作用: 它像一个“契约”或“声明”,告诉编译器某个函数存在、它接受什么类型的参数以及返回什么类型的值。
    • 带来的好处:
      • 先使用后定义: 只要在使用前有函数原型声明,函数的具体实现(定义)可以放在文件的任何位置,甚至在其他文件中。
      • 编译器检查: 编译器会根据原型来检查你的函数调用是否正确(参数个数、类型是否匹配),极大地增强了代码的类型安全。
  2. 函数重载 (Overloading)——这一部分就是C++的特色了
    • 核心理念: 允许在同一个作用域内定义多个同名函数,只要它们的参数列表不同(参数的个数、类型或顺序不同)。这是多态 (Polymorphism) 在编译期的一种体现。
    • 底层原理: 编译器通过一种叫做名字修饰 (Name Mangling) 的技术,为每个重载版本生成一个唯一的内部名称(如_Z4funci),从而在链接时能够区分它们。extern "C" 的作用就是告诉编译器不要进行名字修饰。
    • 重要规则:
      • 返回值类型不能作为区分重载的依据。
      • 调用重载函数时,编译器会根据传入的实参类型进行匹配。如果匹配不唯一(例如 f(10) 对于 f(long) 和 f(double) 都是合法的隐式转换),就会产生二义性 (ambiguous) 编译错误。
  3. 默认参数 (Default Parameters)
    • 核心功能: 允许在函数原型中为参数指定一个默认值。如果在调用时没有提供该参数,编译器会自动使用这个默认值。
    • 重要规则:
      • 默认参数必须从右至左依次指定(可以联想到前面参数是从右向左依次压栈的)。
      • 默认参数的声明通常放在函数原型中。
      • 它也可能与函数重载产生二义性。例如,void f(int); 和 void f(int, int=2); 这两个重载函数,在调用 f(10) 时编译器无法确定该调用哪一个。

回答Q1:追求极致性能的内联函数 (inline)

  1. 动机:函数调用的成本 (COST)

    • 每一次函数调用都有开销:保存现场、参数压栈、跳转、返回、恢复现场等。对于非常简单且调用频繁的函数,这些开销甚至可能超过函数体本身的执行时间。——见前面调用约定讲述的内容
  2. 解决方案:内联函数 (inline)

    • 目的: 既保留函数封装带来的可读性,又通过消除调用开销来提高效率

    • 实现方法: inline 关键字建议编译器不要生成一个真正的函数调用(即没有 call 指令),而是直接将函数体内的代码嵌入 (inline) 到调用点。

      inline int add(int a, int b) {
          return a + b;
      }
      
      int main() {
          int result = add(3, 5);  // 编译器将其展开为 int result = 3 + 5;
          return 0;
      }
      
  3. 使用原则与智慧

    • inline 仅仅是请求: inline 只是对编译器的一个建议,而不是强制命令。编译器会根据自己的优化策略来决定是否采纳这个建议。如果函数过于复杂(如包含循环、递归),编译器通常会忽略 inline 请求。
    • 适用场景: 最适合代码量小、逻辑简单、调用频率高的函数。
    • 明智地运用 (缺点与权衡):
      • 代码膨胀 (Code Bloat): 如果滥用 inline 于大函数,会导致最终生成的可执行文件体积急剧增大。
      • 性能下降: 过大的代码体积会降低指令缓存 (instruction cache) 的命中率。CPU需要频繁地从慢速内存中加载新的指令,反而会降低程序性能,这破坏了程序的空间局部性

C++中的函数式编程

这里我们只讲述FP中最常用、最核心的三个高阶函数:filter、transform、accumulate函数 下面我仅仅举例子,你肯定可以理解

filter

筛选你想要的元素

  • 前面的实践:copy_if

    std::copy_if(InputIt first, InputIt last, OutputIt d_first, UnaryPredicate pred);
    std::copy_if(src.begin(), src.end(), // 遍历源向量 src
                 std::back_inserter(target), // 使用 back_inserter 在 target 末尾插入元素
                 [](int n) { return n % 2 == 0; }); // Lambda 作为谓词,筛选偶数
    
  • 前面的实践:remove_if

    ForwardIt remove_if(ForwardIt first, ForwardIt last, UnaryPredicate pred);
    // 1. remove_if 将所有奇数移动到前面,并返回 new_end 指向第一个偶数
    auto new_end = std::remove_if(src.begin(), src.end(), isEven);
    // 2. src.erase 从 new_end 到物理末尾,真正删除所有偶数
    src.erase(new_end, src.end());
    
  • 新的实践

    • 核心思想: 创建一个“视图 (View)”,它本身不存储数据,而是对源数据的一个懒惰的、可组合的引用。

    • 语法 (管道操作符 |):codeC++

      auto target = source_range | std::views::filter(predicate);

      • source_range: 源数据容器,如 src。
      • |: 管道操作符,将左侧的数据“流向”右侧的操作。
      • std::views::filter(predicate): 创建一个只包含满足 predicate 的元素的视图。
    • 例子:

      auto target = src | std::views::filter(isEven);
      

transform

语法:

auto new_view = source_range | std::views::transform(unary_op);
  • 实例

    auto squares = numbers | std::views::transform([](int n) { return n * n; });
    // 将 numbers 中的每个数平方。
    auto strings = numbers | std::views::transform(...);
    // 将每个整数转换为字符串。
    auto upper_words = words | std::views::transform(...);
    // 将每个字符串单词转换为大写。
    
  • 编译器优化

    • 由于 transform 只是构建了一个视图,整个数据处理链(例如 filter | transform)对编译器是完全可见的。
    • 编译器可以将多个操作融合 (fuse) 成一个单一的循环,避免了中间数据结构的创建和多次遍历,从而生成极为高效的代码。
  • 惰性求值

    1. Ranges 方式 (lazy_view):

      auto lazy_view = numbers | std::views::transform(expensive_operation);
      for (int n : lazy_view) { ... }
      

      执行流程: for 循环请求第一个元素 -> lazy_view 从 numbers 取出 1 -> 将 1 送入 expensive_operation (打印 "computing: 1") -> 返回结果 1 -> for 循环打印 "result: 1"。然后请求第二个元素,重复此过程。

      结果: "computing" 和 "result" 是交替打印的。计算只在绝对必要时才发生。

    2. 传统 STL 方式 (std::transform):

      std::transform(numbers.begin(), numbers.end(), std::back_inserter(instant), expensive_operation);
      for (int n : instant) { ... }
      

      执行流程: std::transform 立即遍历 numbers 的所有元素,对每个元素调用 expensive_operation,并将结果存入 instant。这个过程会先打印出所有的 "computing: 1", "computing: 2", "computing: 3"。然后,for 循环才开始遍历已经填满的 instant 容器,打印所有的 "rerult: 1", "rerult: 4", "rerult: 9"。

      结果: "computing" 和 "rerult" 是分块打印的。这是立即求值 (Eager Evaluation)

accumulate

语法

T accumulate(InputIt first, InputIt last, T init, BinaryOperation op);
  • 实例

    auto sum = std::accumulate(nums.begin(), nums.end(), 0);
    // 初始值为0,默认操作为加法。
    auto sentence = std::accumulate(words.begin(), words.end(), std::string(""));
    // 初始值是一个空字符串 "",操作为字符串加法,将所有单词拼接起来。
    auto product = std::accumulate(..., 1.0, [](double a, double b) { return a * b; });
    // 初始值必须是乘法单位元 1.0,并提供一个自定义的 lambda 作为乘法操作。
    

C++中的实践

一个具体问题——“统计一个字符串列表中的所有大写字母数量”

  • 第一步:转换 (Transform)

    auto capitalCounts = strings | std::views::transform([](const std::string& str) {
        return std::ranges::count_if(str, [](char c) { return std::isupper(c); });
    });
    
    • strings | std::views::transform(...): 这创建了一个惰性视图 capitalCounts。
    • transform 的操作是:对于 strings 中的每个字符串 str,应用 std::ranges::count_if 来计算其中大写字母的数量。
    • std::ranges::count_if 是 count_if 的 Ranges 版本,可以直接作用于一个 range (str)。
    • 结果: capitalCounts 是一个“承诺”,它承诺当你需要时,它可以为你生成一个包含每个字符串大写字母计数的数字序列。此时没有任何计算发生。
  • **第二步:累加 (Accumulate)**codeC++

    return std::accumulate(capitalCounts.begin(), capitalCounts.end(), 0);
    
    • 将 capitalCounts 这个视图送入 std::accumulate。
    • 执行: std::accumulate 开始向 capitalCounts 请求第一个元素 -> capitalCounts 执行 transform 操作计算出第一个字符串的大写数并返回 -> accumulate 累加... 这个过程一直持续到 capitalCounts 结束。

C++的回调函数的增强

C风格的回调函数(Callback)

  • 实现方式:
    1. 通用数据类型 (void)*: 函数的第一个参数 void *base 可以接收任何类型的数组指针。这是一种非常 C 风格的做法,它放弃了类型安全,需要程序员自己通过 memcpy 和指针算术来处理内存。
    2. 通用比较逻辑 (函数指针):
      • int (*compare)(const void *elem1, const void *elem2)
      • 这是 MySort 函数的核心。它是一个函数指针,要求调用者必须传入一个符合这个“签名”(接受两个 const void* 参数,返回 int)的函数。
      • 这个被传入的函数就是 回调函数 (CALLBACK)。MySort 的主逻辑(冒泡排序的循环)是固定的,但在需要比较两个元素大小时,它会**“回调”** 用户提供的 compare 函数来完成这个特定任务。

Lambda表达式

  • 语法:

    [capture list] (parameter list) specifiers -> return type { function body }
    
    • [capture list] (捕获列表): 这是 Lambda 的超能力。它允许 Lambda “捕获”其所在作用域的变量,以便在函数体内部使用。
      • []: 不捕获任何变量。
    • (parameter list): 和普通函数的参数列表一样。
    • specifiers: 可选的说明符,如 mutable (允许在Lambda内部修改按值捕获的变量)。
    • → return type: 可选的返回类型。如果编译器能推断出来,可以省略。
    • { function body }: 函数体。

更加抽象——function

将函数作为一个对象封装起来了

  • std::function

    • 语法std::function<ReturnType(Arg1Type, Arg2Type, ...)>
    • 例如,std::function<int(int, int)> 表示“一个可以接受两个 int 参数并返回一个 int 的可调用对象”。
  • 示例 (opmap):

    • 这个例子堪称完美。它创建了一个 map,其值类型是 std::function<int(int, int)>
    • 我们向这个 map 中存入了四种完全不同类型的可调用对象:
      1. 普通函数指针: add
      2. 函数对象: ADD()
      3. Lambda 表达式: [](int a, int b){...}
      4. std::bind 的结果: NewAdd ( bind 是一个用于适配函数参数的工具,bind(Add3, placeholders::_1, 0, placeholders::_2) 的意思是创建一个新函数,它调用 Add3,第一个参数用新函数的第一个参数,第二个参数固定为0,第三个参数用新函数的第二个参数)。
    • 神奇之处: 尽管它们的底层类型各不相同,但 std::function 将它们全部“抹平”,统一了接口。因此,后面可以通过 opmap["..."](x, y) 的方式以完全相同的语法来调用它们。

加餐:C++的组织方式

这一部分肝不动了,看AI吧

好的,这一整部分内容讲述了一个非常核心的 C++ 编程主题:代码组织与作用域管理。它循序渐进地展示了从 C 语言到现代 C++,管理变量和函数可见性的技术演进,最终落脚到 C++ 最重要的组织工具 —— 命名空间 (namespace),并与传统的 C 预处理器 (Cpp) 进行了对比。

可以把这部分内容分为三个层次:


第一层:C 风格的代码组织与作用域 (第一张图)

  • 程序组织 (Program Organization):
    • 头文件 (Header file, .h): 用于声明 (declaration)。它们是模块的“接口”,通过 extern 关键字声明全局变量和函数,告诉其他文件“这些东西存在,你们可以用”。
    • 源文件 (Source file, .cpp): 用于定义 (definition)。它们是模块的“实现”,提供变量的存储空间和函数的具体代码。
    • #include: 预处理器指令,作用就是把头文件的内容原封不动地“复制粘贴”到源文件中。
  • 作用域 (Scope):
    • 这张图用不同颜色和标签清晰地展示了 C 的四级作用域:
      1. 程序级: 通过 extern 在整个程序(所有链接的文件)中都可见。
      2. 文件级 (File Scope): 使用 static 关键字修饰的全局变量或函数,其可见性被限制在当前文件内。这是一种避免链接错误 (linking errors) 的重要手段。
      3. 函数级: 函数内部声明的变量,只在该函数内有效。
      4. 块级: 在 {} 代码块(如 for 循环)内声明的变量,只在该块内有效。

第二层:现代 C++ 的解决方案 —— namespace

namespace 是 C++ 为了解决 C 语言中“全局命名空间污染”问题而引入的强大工具。在大型项目中,不同模块可能会定义同名变量或函数,static 只能解决文件内部的隔离,但无法解决更复杂的库与库之间的冲突。

  • 核心思想: 创建一个具名的“容器”或“区域”,将代码实体(变量、函数、类等)包裹起来,避免它们与外部的名称冲突。

  • 两种使用形式 (Forms):

    1. using 声明 (using-declaration): using L::k;
      • 作用: 精确地将命名空间中的某一个成员引入到当前作用域。
      • 优点: 精确、安全。只引入你需要的,不会带来意外的名称。这是被优先推荐的方式。
    2. using 指示 (using-directive): using namespace L;
      • 作用: 将命名空间中的所有成员一次性全部引入到当前作用域。
      • 缺点: 危险。它可能会引入大量你不需要甚至不知道的名称,极易重新引发命名冲突。应避免在头文件中使用
  • 丰富的特性 (Details):

    • 别名 (Alias): 可以为很长的命名空间创建一个简短的别名 (namespace ATT = ...)。
    • 开放性 (Open): 可以分多次、在不同文件中对同一个命名空间进行补充和扩展。
    • 可嵌套 (Nestable): 命名空间可以层层嵌套,形成有层次的结构 (L1::L2::f())。
    • 支持重载 (Overloading): 可以在不同命名空间中定义同名函数,通过 using 指令可以将它们引入同一作用域并实现重载。
  • 替代 static: 幻灯片中提到“在约束作用域方面,替代static”。意思是,对于限制全局变量/函数在文件内可见这个需求,更好的现代C++做法是使用匿名命名空间 (anonymous namespace),而不是 static

    namespace {
      // 这里的变量和函数都只在当前文件可见
      int file_local_var = 0;
    }
    
    

第三层:回顾与对比 —— C 预处理器 (Cpp) 的功与过 (第五至八张图)

这部分内容回顾了 C 语言的“上古神器”—— C 预处理器 (Cpp),并将其与 C++ 的现代特性进行对比,阐述了为什么我们应该尽量使用现代特性来替代它。

  • 预处理器的“原罪” (与现代概念格格不入):
    • 预处理器进行的是简单的文本替换,它发生在编译之前,完全不理解 C++ 的作用域、类型、接口等语法概念。
    • 穿透作用域: 它的影响是全局性的,#define 定义的宏会无视所有的 namespaceclass 边界。
    • 危险的例子: gcc -Dsqrt=rand 这个命令在预处理阶段将代码中所有的 sqrt 替换为 rand,导致程序逻辑被完全篡改,这展示了宏的脆弱性和危险性。
  • 宏的“历史功绩”与现代“替代品”:
    • #include: 组织代码 -> 模块 (Modules, C++20)
    • #define 定义常量: const/constexpr
    • #define 定义函数宏: inline 函数 / template
    • #define 定义类型: typedef / using (类型别名)
    • #ifdef/#ifndef: 版本控制、条件编译 -> IDE配置 / if constexpr (C++17)
  • 宏的现状: 尽管有诸多缺点和替代品,宏在某些场景(如头文件保护符、条件编译、字符串化、连接符号等)下仍然是不可或缺的工具。但总的原则是:如果一个功能可以用 C++ 语言本身的特性(const, inline, template, namespace 等)来实现,就绝对不要使用宏。

总结: 这一整部分内容的核心是**“进化”**。它展示了 C++ 如何通过引入 namespace 解决了 C 语言在大型项目中的代码组织和命名冲突问题,并强调了应该用类型安全、尊重作用域的现代 C++ 特性去逐步取代功能强大但又“无法无天”的 C 预处理器宏。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值