11、运算符重载与模板:运算符重载规则、常见运算符重载、函数模板、类模板

好,咱们今天聊点硬核的。运算符重载和模板,这两个东西在C++里属于「看着不难,用好了真香」的类型。我刚开始学的时候,觉得运算符重载不就是把+号改个意思吗?后来在项目里写了一个自定义的矩阵类,才发现这东西要是没用好,代码能写成天书。

模板就更不用说了。说白了,模板就是让编译器帮你写代码。你想想看,要是没有模板,你得为int、float、double各写一套排序函数,那得多累。我当年在一个嵌入式项目里,就因为模板用得好,把代码量砍掉了将近一半。

嗯,咱们一个一个来。

11.1 运算符重载的规则

运算符重载,本质上就是给已有的运算符赋予新的含义。但注意,不是所有运算符都能重载。我记得有个新手问我:「能不能重载::运算符?」我当时就笑了,这玩意儿要是能重载,C++的命名空间就乱套了。

重载的规则其实不多,但每条都很重要:

  • 不能重载的运算符::.*.?:sizeoftypeid。这些是C++的「底线」。
  • 不能改变优先级和结合性:你重载了+号,它还是从左到右算,优先级还是比*低。
  • 不能改变操作数个数:一元运算符重载后还是一元,二元还是二元。
  • 至少有一个用户定义类型:你不能重载int+int,那是编译器的事。

核心原则:运算符重载应该保持语义一致。你重载了+号,别人就期望它做「加法」或「合并」之类的事。我曾经见过有人把+重载成减法,那代码简直没法维护。

重载有两种形式:成员函数和友元函数。我个人的习惯是:如果运算符需要修改左操作数(比如+=),就用成员函数;如果不需要(比如+),就用友元函数。这样更符合直觉。

// 成员函数形式
class Complex {
public:
    Complex operator+(const Complex& other) const {
        return Complex(real + other.real, imag + other.imag);
    }
private:
    double real, imag;
};

// 友元函数形式
class Complex {
    friend Complex operator+(const Complex& a, const Complex& b);
};

Complex operator+(const Complex& a, const Complex& b) {
    return Complex(a.real + b.real, a.imag + b.imag);
}

11.2 常见运算符重载

实际项目中,最常重载的运算符就那么几个。我列个表,你一看就明白:

运算符 典型用途 重载形式 注意事项
+ - * / 数学运算、字符串拼接 友元函数 返回新对象,不要修改原对象
+= -= 复合赋值 成员函数 返回引用,支持链式调用
== != 比较对象是否相等 友元函数 通常需要同时重载==和!=
<< >> 输入输出流 友元函数 第一个参数是流对象
[] 下标访问 成员函数 通常返回引用
() 函数对象(仿函数) 成员函数 参数个数可变

这里我重点说一下<<>>的重载。为什么必须是友元函数?因为第一个参数是ostream&istream&,你不能给标准库的类加成员函数。我曾经在项目里写过一个日志类,重载了<<,用起来就像cout << "hello"一样自然。

class Point {
    friend ostream& operator<<(ostream& os, const Point& p);
    friend istream& operator>>(istream& is, Point& p);
private:
    int x, y;
};

ostream& operator<<(ostream& os, const Point& p) {
    os << "(" << p.x << ", " << p.y << ")";
    return os;
}

istream& operator>>(istream& is, Point& p) {
    is >> p.x >> p.y;
    return is;
}

小技巧:重载++--时,前置版本返回引用,后置版本返回值。后置版本通常加一个int占位参数来区分。比如operator++(int)就是后置++。

11.3 函数模板

函数模板,说白了就是「写一次,用多种类型」。编译器会根据你传入的参数类型,自动生成对应的函数。我刚开始用模板的时候,总觉得它很神秘,后来发现其实就是个「类型占位符」。

最基本的写法是这样的:

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

// 使用
int m1 = max(3, 5);        // T被推导为int
double m2 = max(3.14, 2.7); // T被推导为double

这里有个坑:如果你传max(3, 3.14),编译器会报错,因为两个参数类型不一致。解决办法有两个:要么显式指定类型max<double>(3, 3.14),要么用多个模板参数。

避坑指南:我曾经在项目里写了一个模板函数,参数是引用类型。结果传入临时对象时,编译死活不过。后来才发现,模板参数推导时,引用和const的匹配规则比想象中复杂。建议新手先别玩太花,老老实实用值传递。

函数模板还可以重载。比如你可以写一个专门处理指针的版本:

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

编译器会优先选择最匹配的版本。这就是模板特化的雏形。

11.4 类模板

类模板和函数模板的思路一样,只不过作用在类上。最经典的例子就是std::vectorstd::array。我自己也写过不少类模板,最常用的就是容器类。

template <typename T, int Size>
class Array {
public:
    T& operator[](int index) {
        return data[index];
    }
    const T& operator[](int index) const {
        return data[index];
    }
    int size() const { return Size; }
private:
    T data[Size];
};

// 使用
Array<int, 10> arr;
arr[0] = 42;

类模板的成员函数,如果定义在类外,也需要加上模板声明:

template <typename T, int Size>
T& Array<T, Size>::operator[](int index) {
    return data[index];
}

这里有个细节:模板参数可以是类型,也可以是整数、枚举、指针等。但浮点数和字符串字面量不行。为什么?因为模板是在编译期实例化的,浮点数的比较在编译期有精度问题。

核心要点:类模板的实例化是在编译期完成的。你写了Array<int, 10>Array<int, 20>,编译器会生成两个完全不同的类。这既是优点(效率高),也是缺点(代码膨胀)。

11.5 模板特化与偏特化

有时候,通用模板不能满足所有类型的需求。比如你想为指针类型写一个特殊的版本,这就是模板特化。

// 通用模板
template <typename T>
class Wrapper {
public:
    Wrapper(T val) : value(val) {}
    T get() const { return value; }
private:
    T value;
};

// 全特化:针对指针类型
template <>
class Wrapper<int*> {
public:
    Wrapper(int* val) : value(val) {}
    int get() const { return *value; }
private:
    int* value;
};

偏特化更灵活,只指定部分模板参数:

// 偏特化:针对指针类型(任意类型的指针)
template <typename T>
class Wrapper<T*> {
public:
    Wrapper(T* val) : value(val) {}
    T get() const { return *value; }
private:
    T* value;
};

嗯,这里要注意:函数模板只能全特化,不能偏特化。但你可以用重载来达到类似效果。

11.6 知识体系总览

为了让你更直观地理解本章的知识结构,我画了一张图:

第11章:运算符重载与模板 运算符重载 模板 重载规则 常见运算符 不能重载的运算符 成员 vs 友元 语义一致性 输入输出流 函数模板 类模板 类型推导 非类型参数 模板重载 特化与偏特化 核心目标:代码复用 + 语义自然

这张图把本章的内容分成了两大块。左边是运算符重载,右边是模板。你仔细看,其实它们有一个共同点:都是为了让代码更灵活、更易用。运算符重载让自定义类型用起来像内置类型,模板让代码能适配多种类型。

最后说一句:模板这东西,刚开始会觉得语法别扭,但用多了就习惯了。我建议你从写一个简单的Stack<T>类开始练手,把push、pop、top都实现一遍,再试试特化。等你把这些都跑通了,C++的泛型编程就算入门了。


公众号:蓝海资料掘金营,微信 deep3321