欢迎光临
我们一直在努力

25. 函数模板:告别宏和 void指针

文章目录

  • 引言
  • 一、第一个函数模板
    • 1.1 语法拆解
    • 1.2 调用方式
    • 1.3 模板不是万能的——类型必须支持模板中用到的操作
  • 二、编译器如何生成代码:模板实例化
    • 2.1 两阶段编译
    • 2.2 隐式实例化
    • 2.3 显式实例化
  • 三、模板参数推导规则
    • 3.1 基本的推导
    • 3.2 函数参数中的引用和 const
  • 四、模板与重载:谁先被匹配
  • 五、非类型模板参数
  • 六、函数模板的常用实战模式
    • 6.1 引用封装——免除手动取地址
    • 6.2 比较器封装
    • 6.3 通用的输出函数
  • 七、模板与头文件:为什么模板不能分开编译
  • 总结

本系列为《C++深度修炼:基础、STL源码与多线程实战》第25篇 前置条件:理解函数调用机制(C语言),了解 auto(第13篇),了解引用(第9篇)

引言

C 语言中,要写"对任意类型都适用"的函数,你只有两条路——宏,或者 void*:

// 方案一:宏——没有类型检查,调试时看到的不是原始代码
#define MAX(a, b) ((a) > (b) ? (a) : (b))

// 方案二:void* ——丧失类型安全,调用方必须强转
int max_int(void *a, void *b) {
return *(int*)a > *(int*)b ? *(int*)a : *(int*)b;
}

宏的致命伤:MAX(++x, y) 会把 ++x 展开两次。void* 的致命伤:编译器不知道你在比较 int 还是 double,错误要等到运行时才暴露。

C++ 的函数模板同时解决了这两个问题:像宏一样对任意类型适用,像函数一样有完整的类型检查。

template <typename T>
T max(T a, T b) {
return a > b ? a : b;
}

这就是本章的起点——写出第一个函数模板,理解编译器"按需生成代码"的机制,并避开模板编程中最常见的坑。


一、第一个函数模板

1.1 语法拆解

template <typename T> // ← "T 是一个类型参数,调用时由编译器推导"
T max(T a, T b) { // ← 用 T 就像用 int——可以写参数、返回值、局部变量
return a > b ? a : b;
}

typename 也可以用 class 替代——两者在模板参数声明中完全等价:

template <class T> // 和上面的写法意义完全相同
T max(T a, T b) { return a > b ? a : b; }

1.2 调用方式

#include <iostream>
#include <string>

template <typename T>
T max(T a, T b) { return a > b ? a : b; }

int main() {
// 隐式推导:编译器根据参数类型自动推导 T
std::cout << max(3, 5) << '\\n'; // T = int
std::cout << max(3.14, 2.71) << '\\n'; // T = double

std::string s1 = "apple", s2 = "banana";
std::cout << max(s1, s2) << '\\n'; // T = std::string

// 显式指定:当推导歧义或需要强制类型时
std::cout << max<double>(3, 5.5) << '\\n'; // T = double——3 被隐式转换为 3.0
}

编译器不会生成一个"泛型"的 max 函数。它会在每次调用时,根据 T 的具体类型实例化出一份独立的机器码——相当于编译器帮你把 max<int>、max<double>、max<std::string> 各写了一遍。

1.3 模板不是万能的——类型必须支持模板中用到的操作

struct Point { int x, y; };

int main() {
Point p1{1, 2}, p2{3, 4};
// auto p = max(p1, p2); // ❌ 编译错误:Point 没有 operator>
}

模板代码中用到了 a > b——所以 T 必须支持 operator>。模板的类型约束是隐式的——编译器不会提前检查,而是在实例化时报告"T 没有 operator>"。这是模板报错冗长的根本原因(第27篇会专门讲如何读懂和化解)。


二、编译器如何生成代码:模板实例化

2.1 两阶段编译

模板代码经历两个阶段:

  • 定义时:检查不依赖模板参数的语法(括号匹配、分号、非依赖名称)
  • 实例化时:代入具体类型,检查依赖于模板参数的操作(a > b 对 T 是否合法)
  • template <typename T>
    void foo(T x) {
    std::cout << x.member; // 第一阶段不报错——因为还不知道 T 有没有 member
    int a = "hello"; // ❌ 第一阶段就报错:与 T 无关,肯定不合法
    }

    2.2 隐式实例化

    只有被调用的模板函数才会生成代码:

    template <typename T>
    T add(T a, T b) { return a + b; }

    int main() {
    auto x = add(1, 2); // 生成 add<int>
    auto y = add(1.5, 2.5); // 生成 add<double>
    // add<std::string> 没有被生成——因为没有调用 add(string, string)
    }

    这就意味着:模板可以放在头文件中而不会导致"重复定义"链接错误——编译器在每一处调用生成一份相同的实例化,链接器合并它们(或者编译器用 inline 语义处理)。

    2.3 显式实例化

    如果你想提前生成某份实例化(例如为了减少编译时间,或把实例化集中在一个 .cpp 中):

    // 显式实例化声明(告诉编译器:别在这里生成,有人在别处生成了)
    extern template int max<int>(int, int);

    // 显式实例化定义(告诉编译器:在这里生成这份实例化)
    template int max<int>(int, int);


    三、模板参数推导规则

    3.1 基本的推导

    template <typename T>
    T max(T a, T b) { return a > b ? a : b; }

    max(1, 2); // T = int
    max(1.0, 2.0); // T = double
    // max(1, 2.0); // ❌ 歧义:T 是 int 还是 double?

    当两个参数推导出不同的 T 时,编译直接失败——模板参数推导不会做隐式类型转换(只有少数例外:左值到右值、数组到指针、函数到函数指针、cv 限定符调整)。

    解决方式有两种:

    // 方式一:调用时显式指定
    max<double>(1, 2.0); // T = double,1 被转换为 1.0

    // 方式二:用两个模板参数
    template <typename T1, typename T2>
    auto max(T1 a, T2 b) { return a > b ? a : b; }

    max(1, 2.0); // ✅ T1 = int, T2 = double,返回类型由 ?: 规则推导

    3.2 函数参数中的引用和 const

    template <typename T>
    void f(T param); // param 是值类型——顶层 const 被忽略

    template <typename T>
    void g(T &param); // param 是引用——const 被保留

    int x = 42;
    const int cx = 42;
    const int &rx = x;

    f(x); // T = int
    f(cx); // T = int(顶层 const 被忽略)
    f(rx); // T = int(引用性被忽略——rx 是 const int 的引用,T 推导为 int)

    g(x); // T = int
    g(cx); // T = const int(const 被保留)
    g(rx); // T = const int

    这是模板参数推导中最容易出错的地方,尤其当你把模板函数和 std::forward 结合使用时(第31篇会详细展开)。


    四、模板与重载:谁先被匹配

    // 普通函数
    int max(int a, int b) { return a > b ? a : b; }

    // 模板函数
    template <typename T>
    T max(T a, T b) { return a > b ? a : b; }

    int main() {
    max(1, 2); // 调用普通函数——当普通函数和模板都匹配时,优先选普通函数
    max<>(1, 2); // 调用模板——<> 告诉编译器"只考虑模板"
    max(1.0, 2.0); // 调用模板——普通函数的参数类型不匹配,只有模板可用
    }

    重载决议的顺序:

  • 如果有完全匹配的非模板函数——选它
  • 如果有更特化的模板——选更特化的
  • 其他情况——选唯一可用的模板
  • 都不行——编译错误

  • 五、非类型模板参数

    除了类型,模板参数还可以是编译期常量:

    // size 不是类型——它是编译期已知的整数值
    template <typename T, size_t N>
    size_t array_size(T (&arr)[N]) {
    return N;
    }

    int main() {
    int arr[10];
    double darr[5];

    std::cout << array_size(arr) << '\\n'; // 10
    std::cout << array_size(darr) << '\\n'; // 5
    // T 分别推导为 int 和 double,N 分别推导为 10 和 5
    }

    非类型模板参数可以是:整型常量、枚举、指针(指向全局/静态对象)、引用、nullptr_t、C++20 起还支持浮点和类类型(受限)。

    // 编译期计算阶乘(模板元编程的经典入门例子)
    template <unsigned N>
    struct Factorial {
    static const unsigned value = N * Factorial<N 1>::value;
    };

    template <>
    struct Factorial<0> {
    static const unsigned value = 1;
    };

    int main() {
    std::cout << Factorial<5>::value << '\\n'; // 120——编译期计算完成
    }


    六、函数模板的常用实战模式

    6.1 引用封装——免除手动取地址

    template <typename T>
    void zero_init(T &obj) {
    std::memset(&obj, 0, sizeof(T));
    }
    // 对 int、double、struct 都适用——不需要 void*

    6.2 比较器封装

    template <typename T, typename Compare>
    const T& min(const T &a, const T &b, Compare comp) {
    return comp(a, b) ? a : b;
    }
    // 第三个参数可以是函数指针、lambda、函数对象——编译器会为每种组合生成独立实例化

    6.3 通用的输出函数

    template <typename Container>
    void print(const Container &c, const char *sep = " ") {
    for (const auto &x : c) {
    std::cout << x << sep;
    }
    std::cout << '\\n';
    }


    七、模板与头文件:为什么模板不能分开编译

    这是初学者最困惑的问题之一:

    // ===== max.h =====
    template <typename T>
    T max(T a, T b); // 声明

    // ===== max.cpp =====
    template <typename T>
    T max(T a, T b) { // 定义
    return a > b ? a : b;
    }

    // ===== main.cpp =====
    #include "max.h"
    int main() {
    max(1, 2); // ❌ 链接错误:找不到 max<int> 的实现!
    }

    原因: 编译器编译 max.cpp 时,不知道你会调用 max<int>,所以没有生成 max<int> 的实例化。编译 main.cpp 时,看到声明但没有定义——也生成不了。最终链接器找不到 max<int> 的机器码。

    解决方案——把实现放在头文件中:

    // ===== max.hpp =====
    #pragma once

    template <typename T>
    T max(T a, T b) { // 定义直接放在头文件中
    return a > b ? a : b;
    }

    或者用 #include 技巧(不太推荐但有时需要):

    // ===== max.h =====
    template <typename T> T max(T a, T b);
    #include "max_impl.hpp" // 在头文件末尾包含实现

    这也是为什么 STL 的头文件(<vector>、<algorithm>)里全是模板实现——没有对应的 .cpp 文件。


    总结

    函数模板是 C++ 泛型编程的入口——它让你写一份代码适用所有类型,同时享有完整的类型安全和编译期优化机会:

  • template <typename T> 声明类型参数——typename 和 class 等价,都是"T 是一个类型占位符"
  • 模板在调用时按需实例化——每种类型生成一份独立的机器码,和手写不同版本一样快
  • 模板参数推导不做隐式转换——max(1, 2.0) 直接编译失败;要么显式指定 max<double>(1, 2.0),要么用两个模板参数
  • 重载决议中普通函数优先于模板——但可以用 func<>(args) 强制调用模板版本
  • 非类型模板参数(<size_t N>)让你把编译期常量传入模板,实现如数组大小的自动推导
  • 模板必须定义在头文件中——编译器在实例化时需要看到完整的函数体,不能像普通函数一样声明和定义分离
  • 下一篇文章我们将进入类模板的世界——如何用 template 构建泛型的容器、RAII 包装器,以及 C++17 的 CTAD 如何让我们写更少的 <>。


    动手练习:

  • 写一个 my_abs 函数模板,对 int、long、float、double 分别调用并验证返回正确的绝对值
  • 写一个 swap_if_greater 函数模板——如果 a > b 就交换它们;用 int 和 std::string 测试
  • 故意调用 max(1, 2.0),读懂编译器报错——然后分别用显式指定和双模板参数两种方式修复
  • 写一个接受函数指针作为模板参数的 invoke,把"传入的二元函数"应用到两个参数上——用 std::plus 和 lambda 分别测试
  • 把第 3 题的 swap_if_greater 拆成声明 + 定义分离的头文件/cpp——观察链接错误,然后改回头文件实现,理解为什么模板不能分开编译
  • 赞(0)
    未经允许不得转载:171主机测评 » 25. 函数模板:告别宏和 void指针
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址