第6章 函数

第6章 函数

6.1 函数基础

6.1.1 函数返回类型

  1. 返回类型选择

    • 几乎所有类型都能作为返回类型(int、float、struct等)。

    • void类型表示函数不返回任何值。

  2. 不能直接返回数组或函数类型

    • 例如:int func()[10];(错误,不能返回数组)

    • 例如:int func()();(错误,不能返回函数)

  3. 可以返回指针

    • 可以返回“指向数组的指针”:

      int (*func())[10]; // 返回指向10个int的数组的指针
    • 可以返回“指向函数的指针”:

      int (*func())(); // 返回指向无参数、返回int的函数指针

6.1.2 进阶内容

  • 返回数组指针和函数指针的写法较为复杂,常用于需要返回复杂数据结构或回调函数的场景。

  • 必须确保被返回的数组或函数指针所指向的对象在函数外部依然有效(如用static修饰或为全局变量)。

6.1.3 例子辅助记忆

  1. 返回数组指针

int (*getArray())[10] {
    static int arr[10];
    return &arr;
}
  1. 返回函数指针

int foo() { return 123; }
int (*getFunc())() {
    return foo;
}

6.1.4 typedef定义

#include <stdio.h>

// 1. 定义一个函数指针类型
typedef int (*FuncPtr)(int, int);

// 2. 普通函数
int add(int x, int y) {
    return x + y;
}

// 3. 返回函数指针的函数
FuncPtr getAddFunc() {
    return add;
}

int main() {
    // 4. 使用返回的函数指针
    FuncPtr fp = getAddFunc();
    printf("%d\n", fp(10, 20)); // 输出30
    return 0;
}

6.2 参数传递

6.2.1 传值参数

虽然指针形参可以修改对象内容,但指针本身的值(地址)不会影响实参。

6.2.1.1. 指针本身的传递

在C++中,形参是指针类型时,传递的是指针的值(即地址)。这种传递方式是“值传递”,也就是说,函数内部的指针形参和外部的实参指针,其实是两个独立的指针变量,只是指向同一个地址(对象)。

  • 你在函数内部改变指针本身的值(让它指向别的地方),不会影响外部的实参指针。

例子:

void foo(int* p) {
    p = nullptr; // 只改变了形参p的值,实参不会变
}
int* ptr = new int(42);
foo(ptr);
// 调用后,ptr 仍然指向原来的内存

这里,pptr是两个独立的指针变量。p = nullptr只让函数内部的p变成了空指针,外部的ptr完全没被影响。


6.2.1.2. 指针指向的对象值可以被修改

虽然指针本身是“值传递”,但你可以通过指针间接访问、修改它所指向的对象。这样,外部的变量内容就被改变了。

例子:

void foo(int* p) {
    *p = 0; // 通过指针,修改了指向的对象的值
}
int x = 42;
foo(&x);
// 调用后,x 变成了 0

这里,*p = 0是修改了p所指向对象(也就是x)的值。外部变量x的内容被改变了。

  • 指针变量本身的地址传递(值传递),不会影响外部指针变量。

  • 但通过指针可以修改它所指向的对象内容,从而影响外部对象。

系统资源不能拷贝只能引用

6.2.2 const形参和实参

6.2.2.1. 顶层const与函数形参

  • 顶层const作用于对象本身(如const int a),表示a不可修改。

  • 当用实参初始化形参时,形参的顶层const会被忽略 也就是说,形参的const修饰对传参过程无影响,既可以传const int也可以传int

  • 函数重载

    时,顶层const会被忽略,不能仅靠顶层const区分两个函数:

    C++

    void fcn(int);         // 合法
    void fcn(const int);   // 错误!和上面重复,因为顶层const被忽略

6.2.2.2 指针/引用与const的结合

  • 底层const作用于指针所指的对象(如const int* p),表示*p不可修改。

  • 常量引用const T&)可以绑定到常量对象、字面值、类型转换产生的临时对象等,灵活性高

  • 普通引用(T&只能绑定到同类型的非常量对象,不能绑定常量、字面值或需要类型转换的对象。

示例:

C++

void foo(const int &a); // 可以接受int、const int、字面值、表达式等
void bar(int &a);       // 只能接受非常量int对象,其他都不行

6.2.2.3 常量引用的优势

  • 避免误导:如果函数不会修改形参,应该用常量引用(const &),否则调用者会以为实参可能被修改。

  • 扩大适用范围:常量引用可以接受更多类型的实参(见上文),普通引用限制很大。

  • 提高代码健壮性:有助于类型安全和接口设计的鲁棒性。


6.2.2.4. 错误写法带来的后果

  • 如果错误地把形参写成普通引用(T&),会导致:

    1. 不能接受const对象、字面值、临时对象等。

    2. 影响上层调用者的形参设计,导致接口僵化或错误蔓延。

正确做法:除非确实需要在函数内部修改对象,否则一律用const &

6.2.3 可变形参的函数

6.2.3.1 三种主流可变参数

  1. std::initializer_list(类型相同)

1.1 定义与用途

  • 适用于参数个数不定,且类型全部相同的函数。

  • initializer_list是C++标准库提供的类型,本质是不可变的只读数组

  • 定义于头文件<initializer_list>

1.2 语法举例

#include <iostream>
#include <initializer_list>

void error_msg(std::initializer_list<std::string> il) {
    for (auto beg = il.begin(); beg != il.end(); ++beg)
        std::cout << *beg << " ";
    std::cout << std::endl;
}

int main() {
    error_msg({"File not found", "in line 42", "config.txt"});
    error_msg({"Error: invalid input"});
}

1.3 关键要点

  • 传参时必须用花括号包裹元素,如error_msg({"A", "B", "C"})

  • initializer_list中的元素只读,无法修改。

  • 可以和其他形参联合出现。


  1. 可变参数模板(类型可变,C++11及后)

2.1 定义与用途

  • 适用于参数数量和类型都不确定的情况。

  • C++11引入的“参数包”机制,极其强大灵活。

  • 语法稍复杂,常用于日志、格式化字符串等通用库。

2.2 语法举例(简单示意)

#include <iostream>

template<typename... Args>
void error_msg(Args... args) {
    (std::cout << ... << args) << std::endl; // C++17折叠表达式
}

int main() {
    error_msg("Error: ", 404, ", in file: ", "main.cpp");
}

深入内容见C++模板章节。

  1. 省略符形参(C风格)

3.1 定义与用途

  • 语法:void foo(int count, ...)

  • 只能出现在形参列表最后。

  • 主要为与C语言老代码接口兼容而设,现代C++项目不推荐新用

3.2 语法举例

#include <cstdarg> // C++风格头文件
#include <iostream>

void printInts(int count, ...) {
    va_list args;
    va_start(args, count);
    for (int i = 0; i < count; ++i) {
        int val = va_arg(args, int);
        std::cout << val << " ";
    }
    va_end(args);
    std::cout << std::endl;
}

int main() {
    printInts(3, 10, 20, 30);
}

3.3 关键限制

  • 省略符形参不能做类型检查,容易出错。

  • 只能安全处理C/C++内置类型(int、double等),类类型对象无法正确拷贝

  • 只建议用在与C库(如printf、scanf)交互时。

  1. 可变参数模板函数声明

template<typename... Args>
void func(Args... args);
  • Args...模板参数包args...函数参数包

  1. 展开参数包的常见方式

(1) 递归展开(C++11风格)

#include <iostream>

void print() {} // 递归终止函数

template<typename T, typename... Args>
void print(T first, Args... rest) {
    std::cout << first << " ";
    print(rest...); // 递归展开
}

调用示例:print(1, "abc", 3.14);

(2) 折叠表达式(C++17及后)

template<typename... Args>
void print(Args... args) {
    (std::cout << ... << args) << std::endl; // 一行打印所有参数
}

这种写法更简洁,但只适用于C++17及后。

6.2.4 inline 内联函数 与 constexpr 常量表达式函数

  • inline (内联函数)

    • 目的:向编译器请求在调用点将函数体展开,以消除函数调用的开销。

    • 本质:用空间换时间,适用于短小、频繁调用的函数。

    • 关键inline 只是一个建议,编译器可以忽略。

  • constexpr (常量表达式函数)

    • 目的:定义一个能在编译期计算出结果的函数。

    • 要求:返回类型和所有参数都必须是字面值类型,函数体逻辑简单(C++11要求只有一条return,后续版本放宽)。

    • 特性constexpr 函数被隐式地当作 inline 函数。当传入常量表达式时,它返回常量表达式;否则,就像普通函数一样在运行时计算。

  • 共同点与实践

    • inlineconstexpr 函数的定义都必须放在头文件中,因为编译器在每个使用它们的地方都需要看到完整的函数体才能进行优化。


6.2.5 函数指针

这是本次学习的重点,也是最复杂的部分。

  • 核心概念:一个指向函数代码入口地址的指针。它的类型由函数签名(返回类型 + 形参列表)唯一决定。

  • 声明语法(关键!)

    • 返回类型 (*指针名)(形参列表);

    • 括号 (\*指针名) 绝对不能少,否则就变成了“返回指针的函数声明”。

  • 使用

    • 赋值:函数名自动转换为指针,ptr = my_func;

    • 调用:像普通函数一样调用,result = ptr(arg1, arg2);

  • 三大复杂场景及其简化方案

    1. 函数指针作为形参

      • 问题:直接写类型 void func(int (*)(char)); 很冗长。

      • 简化:使用 using 或typedef创建类型别名。

        using IntFuncPtr = int(*)(char);
        void func(IntFuncPtr p);
    2. 返回函数指针

      • 问题:语法极其晦涩,如 int (*get_func(char))(int, int);

      • 简化方案(推荐顺序):

        1. 尾置返回类型 (C++11):最清晰直观。

          auto get_func(char op) -> int(*)(int, int);
        2. 类型别名 (using):模块化,易于理解。

          using OperationPtr = int(*)(int, int);
          OperationPtr get_func(char op);
    3. 处理重载函数

      • 必须使用一个类型完全匹配的函数指针来接收地址,以帮助编译器消除歧义。

  • decltype 的应用

    • decltype(函数名) 返回的是函数类型,不是指针。

    • 需要手动加 * 才能得到指针类型:decltype(my_func)* ptr;

6.2.6 核心工具:assertNDEBUG**

  1. assert 断言:开发期的“逻辑警察”

    • 本质:一个定义在 <cassert> 头文件中的预处理宏

    • 工作原理:

      assert(expression)
      • expression假 (false):程序输出详细错误信息(表达式、文件名、行号)并立即终止

      • expression真 (true):什么也不做,程序继续。

    • 核心用途:用于检查程序员认为“绝不应该发生”的内部逻辑错误。它是一种开发阶段的调试辅助,而非面向用户的错误处理机制。

  2. NDEBUG 宏:调试模式的“总开关”

    • 联动机制:

      • 未定义 NDEBUG (默认状态,调试模式):所有 assert 都会被编译并执行运行时检查。

      • 已定义 NDEBUG (发布模式):所有 assert 在预处理阶段被替换为空语句,完全从最终程序中移除,无任何性能开销。

    • 最佳实践:通过编译器命令行选项来定义NDEBUG,以区分构建版本。

      • g++/clang: g++ -DNDEBUG main.cpp

      • Visual Studio: 在项目属性中为“Release”配置定义 NDEBUG


二、自定义调试代码

  • 标准模式:使用 #ifndef NDEBUG ... #endif 来包裹仅在调试模式下需要编译的代码块。

  • 效果:这些代码(如详细日志输出、状态检查)在发布模式下会自动被预处理器忽略,保持发布版本的代码纯净、高效。

    void some_function() {
        #ifndef NDEBUG
            // 这部分代码只在调试模式下存在
            std::cerr << "Entering function: " << __func__ << std::endl;
        #endif
        // ... 核心逻辑 ...
    }

三、调试信息“五件套”:预定义宏

为了精确定位问题,C++提供了以下内置宏:

描述 示例
__FILE__ 当前源文件名 "main.cpp"
__LINE__ 当前代码行号 42
__func__ 当前函数名 "my_function"
__DATE__ 编译日期 "Jul 18 2025"
__TIME__ 编译时间 "14:08:48"

四、核心原则与实践口诀

口诀记忆:

开发用assert,发布关NDEBUG 条件编译包调试,信息定位靠五宏。 assert只管程序员的错,不管用户的“锅”。

易错点与注意事项:

  1. 功能区分assert 不是错误处理(try-catch)或异常机制的替代品。它绝不能用于处理可预期的运行时错误(如用户输入错误、文件打开失败等)。

  2. 避免副作用assert的表达式中不应包含任何会修改程序状态的操作(如 assert(x++ > 0)),因为这些操作在发布模式下会消失,导致调试版和发布版行为不一致。

  3. 全局性NDEBUG的定义对所有包含<cassert>的文件都有效,是项目级别的开关。

发表评论