引言

很多C++初学者会被“重载”和“重写”绕晕——明明都是“同名函数”,为啥用法、效果天差地别?这篇文章会用 “通俗比喻+分步拆解+代码实操+内存可视化” 的方式,从“是什么→怎么用→底层为啥能实现”一步步讲透,哪怕是刚学C++类和继承的新手,也能彻底搞懂!

一、先搭框架:重载和重写的核心区别

在深入细节前,先记住一个通俗的比喻,帮你快速区分:

  • 函数重载:好比“一家奶茶店的同一个窗口,能点原味、珍珠、芋圆奶茶”——名字(窗口)相同,提供的“参数”(配料)不同,下单时(调用函数)直接选对应的配料,店员(编译器)当场确认(编译时)给你做哪种。
  • 函数重写:好比“连锁奶茶店,总店(基类)有‘招牌奶茶’配方(虚函数),分店(子类)可以沿用配方,也能改配方(重写)”——名字(招牌奶茶)相同,配方(实现)不同,你到分店(子类对象)点单时(调用函数),才知道喝到的是总店还是分店的味道(运行时确定)。
    我们先通过下面的表格快速get核心差异,后面再逐个拆解:
对比维度 函数重载 函数重写(覆盖)
核心定位 同一作用域的“同名多实现” 继承体系的“子类覆盖基类”
作用域 同一个(如同一类、全局) 不同(基类 vs 子类)
函数名 必须相同 必须相同
参数列表 必须不同(类型/个数/顺序,至少一项) 必须完全一致(含const、引用等修饰)
返回值 可相同可不同(不影响重载) 默认必须相同(协变除外,后面详解)
虚函数要求 不需要(和virtual无关) 基类必须加virtual(子类可省略,但不推荐)
调用时怎么选 看“实参类型/个数”(编译时确定) 看“对象实际类型”(运行时确定)
底层原理 名字修饰(给函数起“唯一别名”) 虚函数表+虚指针(替换函数地址)
实际用途 简化相似功能接口(如打印int/double) 实现多态(如动物叫、形状绘图)

二、函数重载:同一作用域的“同名不同参”

1. 通俗理解:啥是函数重载?

比如你要写“加法函数”:加两个int、加两个double、加三个int——如果不用重载,你得写 add_int 、 add_double 、 add_int3 三个不同名字的函数,调用时还要记名字,麻烦!

函数重载就是:给这些“功能相似、只是处理的数据不同”的函数起同一个名字,让编译器帮你区分该调用哪个。

2. 重载的3个“硬性要求”(少一个都不行)

必须同时满足,否则编译器不认为是重载:

  1. 函数名完全相同;
  2. 同一作用域(比如都在全局,或都在同一个类里);
  3. 参数列表不同(满足任意一个即可):
    • 参数个数不同(如 add(2,3) vs add(2,3,4) );
    • 参数类型不同(如 add(2,3) vs add(2.5,3.5) );
    • 参数顺序不同(如 add(int a, double b) vs add(double a, int b))。

⚠️ 关键误区1:返回值不同,不能当重载!
比如下面这两个函数,编译器会报错(“重定义”):

int add(int a, int b) { return a+b; }
double add(int a, int b) { return (double)(a+b); } // 报错:参数列表相同,返回值不同不算重载

为什么呢?因为调用时你没法告诉编译器要哪个返回值: add(2,3) ——你想要int还是double?编译器分不清,所以返回值不参与“重载区分”。

3.函数重载代码示例:

#include <iostream>
using namespace std;

// 重载版本1:两个int相加
int add(int a, int b) {
    cout << "调用int版add:";
    return a + b;
}

// 重载版本2:两个double相加(类型不同)
double add(double a, double b) {
    cout << "调用double版add:";
    return a + b;
}

// 重载版本3:三个int相加(个数不同)
int add(int a, int b, int c) {
    cout << "调用3个int版add:";
    return a + b + c;
}

// 重载版本4:int+double(顺序不同,和下面的double+int是两个重载)
double add(int a, double b) {
    cout << "调用int+double版add:";
    return a + b;
}

int main() {
    cout << add(10, 20) << endl;        // 匹配int版 → 输出30
    cout << add(1.5, 2.5) << endl;      // 匹配double版 → 输出4.0
    cout << add(1, 2, 3) << endl;       // 匹配3个int版 → 输出6
    cout << add(5, 3.8) << endl;        // 匹配int+double版 → 输出8.8
    return 0;
}

运行结果会精准匹配对应的重载版本——编译器在编译时就“看实参”确定了要调用哪个函数,这就是“静态绑定”。

4. 底层拆解:编译器是怎么区分重载函数的?(名字修饰)

有人可能会问:“函数名明明一样,编译器怎么知道该调用哪个?”
答案是:编译器会给每个重载函数“偷偷改名字”——这个过程叫“名字修饰(Name Mangling)”。

(1)名字修饰的规则(以GCC编译器为例)

编译器会把“函数名+参数类型”拼接成一个唯一的标识符,格式大概是: _Z + 函数名长度 + 函数名 + 参数类型缩写
参数类型缩写(常见): int→i 、 double→d 、 char→c 、 指针→P 、 引用→R

比如上面的 add 函数,修饰后会变成这样:

  • int add(int a, int b) → _Z3addii (_Z是前缀,3是add长度,ii是两个int)
  • double add(double a, double b) → _Z3adddd (dd是两个double)
  • int add(int a, int b, int c) → _Z3addiii (iii是三个int)
  • double add(int a, double b) → _Z3addid (i是int,d是double)

(2)通俗理解名字修饰

相当于编译器给每个重载函数起了“唯一别名”——编译后,这些函数的名字其实不一样了,链接器就能精准找到对应的函数。
比如你调用 add(1.5,2.5) ,编译器先识别实参是两个double,然后去找“别名”是 _Z3adddd 的函数,这就是重载能生效的核心。

(3)补充:不同编译器的名字修饰差异

  • GCC:上面讲的 _Z+长度+函数名+参数缩写 ;
  • MSVC(VS编译器):规则更复杂,比如 int add(int a,int b) 会变成 ?add@@YAHHH@Z ;
  • 这也是为啥“C++的重载在C语言里用不了”——C语言没有名字修饰,同名函数会冲突。

三、函数重写:运行时的“多态实现术”

函数重写是实现多态的核心

1. 通俗理解:啥是函数重写?

比如你有一个“动物类”,里面有个“叫”的函数;然后有“猫类”“狗类”继承自动物类——猫的叫声是“喵喵”,狗的是“汪汪”,这时候就可以让子类“重写”基类的“叫”函数,实现“不同动物,叫声不同”。

核心逻辑:子类继承基类后,对基类中用 virtual 修饰的虚函数,提供“和原函数签名完全一致”的新实现——调用时,根据“对象的实际类型”来决定用基类还是子类的实现(而不是看指针/引用的类型)。

2. 重写的4个“硬性要求”(少一个就不是重写)

  1. 函数名完全相同;
  2. 作用域不同(基类和子类);
  3. 函数签名完全一致(参数列表、 const 修饰都要一样);
  4. 基类函数必须用 virtual 修饰(子类可以省略 virtual ,但推荐加上 override 关键字显式声明,避免出错)。

3.函数重写代码示例:

#include <iostream>
using namespace std;

// 基类:动物
class Animal {
public:
    // 虚函数:用virtual修饰,允许子类重写
    virtual void speak() {
        cout << "动物发出通用叫声~" << endl;
    }
    // 非虚函数:没有virtual,子类不能重写(只能隐藏)
    void eat() {
        cout << "动物吃食物~" << endl;
    }
};

// 子类:猫(继承自动物)
class Cat : public Animal {
public:
    // 重写基类的虚函数speak()
    // override关键字:显式告诉编译器“我要重写基类虚函数”,写错了会报错(推荐用)
    void speak() override {
        cout << "猫咪喵喵叫~" << endl;
    }
    // 隐藏基类的非虚函数eat()(不是重写,后面讲区别)
    void eat() {
        cout << "猫咪吃小鱼干~" << endl;
    }
};

// 子类:狗(继承自动物)
class Dog : public Animal {
public:
    // 重写基类的虚函数speak()
    void speak() override {
        cout << "狗狗汪汪叫~" << endl;
    }
};

int main() {
    // 关键:用基类指针/引用指向子类对象(触发多态的前提)
    Animal* animal_ptr = nullptr;

    // 指向Cat对象
    animal_ptr = new Cat();
    animal_ptr->speak(); // 调用Cat::speak() → 喵喵叫(多态生效)
    animal_ptr->eat();   // 调用Animal::eat() → 动物吃食物(非虚函数,不触发多态)

    // 指向Dog对象
    animal_ptr = new Dog();
    animal_ptr->speak(); // 调用Dog::speak() → 汪汪叫(多态生效)

    delete animal_ptr;
    return 0;
}

运行结果:
猫咪喵喵叫~
动物吃食物~
狗狗汪汪叫~

这里的关键: animal_ptr 是 Animal* 类型,但调用 speak() 时,实际执行的是“指向对象的子类实现”——这就是多态,而这一切的核心是 virtual 关键字和重写。

4. 底层拆解:重写为啥能实现多态?(虚函数表+虚指针)

这是初学者最难懂的部分,我们用“比喻+内存图+分步流程”讲透,保证你能理解!

(1)核心原理:虚函数表(vtable)和虚指针(vptr)

C++编译器为了实现多态,搞了两个“隐藏工具”:

  • 虚函数表(vtable):每个包含虚函数的类,都会有一个“全局唯一的虚函数表”——本质是一个“函数指针数组”,里面存的是这个类中所有虚函数的“内存地址”。
  • 虚指针(vptr):每个包含虚函数的类的对象,都会在内存中“偷偷多占4字节(32位)或8字节(64位)”,用来存储一个指针——这个指针就是虚指针,指向所属类的虚函数表。

(2)通俗比喻:虚函数表=“函数地址通讯录”,虚指针=“通讯录索引”

  • 基类 Animal 有个虚函数 speak() ,编译器会给 Animal 建一个“通讯录”(vtable),里面记着 Animal::speak() 的地址;
  • 子类 Cat 继承 Animal 后,会“复制”基类的通讯录,然后把 speak() 对应的地址“改成自己的 Cat::speak() 的地址”(这就是重写的底层);
  • 当你创建 Cat 对象时,这个对象会有一个“索引”(vptr),指向 Cat 的通讯录;
  • 调用 animal_ptr->speak() 时,编译器会通过 animal_ptr 找到对象的“索引”(vptr),再通过索引找到 Cat 的通讯录(vtable),最后根据通讯录里的地址,调用 Cat::speak() ——这就是“动态绑定”。

(3)内存布局可视化

我们用“64位系统”(vptr占8字节)来展示对象的内存结构,帮你直观理解:

① 基类Animal的内存结构

Animal 类有一个虚函数 speak() ,所以:

  • Animal 类的vtable(全局唯一): [0] = &Animal::speak (数组第0个元素是 speak() 的地址);
  • Animal 对象的内存: [vptr(8字节)] → vptr指向 Animal 的vtable。

② 子类Cat的内存结构

Cat 继承 Animal ,并重写 speak() :

  • Cat 类的vtable(全局唯一): [0] = &Cat::speak (复制基类vtable后,替换了 speak() 的地址);
  • Cat 对象的内存: [vptr(8字节)] → vptr指向 Cat 的vtable(继承自基类,但vptr指向的是子类的vtable)。

③ 调用 animal_ptr->speak() 的完整流程(分步拆解)

  1. animal_ptr 是 Animal* 类型,指向一个 Cat 对象;
  2. 编译器看到 speak() 是虚函数,知道要“动态绑定”,所以不会直接确定函数地址;
  3. 运行时,先通过 animal_ptr 找到 Cat 对象的内存;
  4. 从对象内存中取出 vptr (虚指针);
  5. 通过 vptr 找到 Cat 类的vtable(虚函数表);
  6. 从vtable中找到 speak() 对应的地址(此时是 Cat::speak() 的地址);
  7. 调用这个地址对应的函数——所以输出“猫咪喵喵叫~”。

(4)补充:多个虚函数的vtable结构

如果基类有多个虚函数,vtable会按“函数声明顺序”存储地址:

class Animal {
public:
    virtual void speak() { ... } // 声明顺序1
    virtual void run() { ... }   // 声明顺序2
};

class Dog : public Animal {
public:
    void speak() override { ... } // 重写顺序1的函数
    void run() override { ... }   // 重写顺序2的函数
};
  • Animal 的vtable: [0]=&Animal::speak, [1]=&Animal::run ;

  • Dog 的vtable: [0]=&Dog::speak, [1]=&Dog::run ;
    调用时,按声明顺序找对应的地址。

5. 重写的特殊情况:协变返回值

之前说“重写时返回值必须和基类一致”,但有个例外——协变返回值:

  • 基类虚函数返回“基类指针/引用”;
  • 子类重写时可以返回“子类指针/引用”(必须是public继承关系)。

代码示例:

class Animal {};
class Cat : public Animal {};

class Base {
public:
    virtual Animal* create() { return new Animal(); } // 返回基类指针
};

class Derived : public Base {
public:
    Cat* create() override { return new Cat(); } // 返回子类指针(协变,允许)
};

底层原因:子类指针可以隐式转换为基类指针,所以vtable中存储的地址类型依然兼容。

四、容易混淆的3个概念:重载 vs 重写 vs 隐藏(避坑重点)

很多初学者会把“函数隐藏”和重写、重载搞混,这里用代码示例明确区分:

1. 函数隐藏(子类隐藏基类同名函数)

定义:子类中定义了一个“和基类同名,但基类函数不是虚函数”的函数——子类函数会“隐藏”基类的同名函数(不管参数是否相同)。

代码示例:

class Father {
public:
    void func(int a) { cout << "Father::func(int) " << a << endl; }
};

class Son : public Father {
public:
    // 基类func不是虚函数,子类同名函数→隐藏
    void func(double a) { cout << "Son::func(double) " << a << endl; }
};

int main() {
    Son son;
    son.func(10);    // 调用Son::func(double) → 10被隐式转为double,输出10.0
    son.Father::func(10); // 要调用基类的,必须显式指定作用域
    return 0;
}

关键区别:隐藏不会触发多态,调用时只看“调用者的类型”(而重写看“对象实际类型”)。

2.三者的核心区别表

特性 函数重载 函数重写 函数隐藏
基类函数要求 无(不需要virtual) 必须是virtual 无(不能是virtual,否则是重写)
参数列表 必须不同 必须相同 可相同可不同
作用域 同一作用域 基类vs子类 基类vs子类
绑定时机 编译期 运行期 编译期
核心差异 同名不同参 子类覆盖基类虚函数 子类隐藏基类非虚函数

五、常见误区+避坑技巧

  1. 误区1:参数默认值不同,可以重载?
    不行!默认参数不算“参数列表不同”,编译器会认为是同一个函数:
void func(int a, int b=10) { ... }
void func(int a) { ... } // 报错:重定义(默认参数不改变参数列表)
  1. 误区2:子类重写时,参数加了const就算不同?
    算!参数的 const 修饰会影响函数签名,重写时必须完全一致:
class Base {
public:
    virtual void func(int a) { ... } // 无const
};

class Derived : public Base {
public:
    // 错误:参数加了const,和基类签名不同,不是重写(是隐藏)
    void func(const int a) override { ... } 
};
  1. 误区3:重写时,子类函数的访问权限可以比基类严格?
    可以,但不推荐!比如基类函数是 public ,子类重写为 private ——通过基类指针调用时,编译时检查基类权限(允许),运行时调用子类函数(但子类权限是private,会报错)。

  2. 避坑技巧

    1. 重写时必加 override 关键字:编译器会帮你检查是否真的重写了基类虚函数(比如参数写错、基类没加virtual,都会报错);
    2. 重载时尽量让参数“类型/个数”差异明显,避免编译器歧义(比如 add(int, double) 和 add(double, int) ,虽然是重载,但调用时可能需要显式转换);
    3. 不要在子类中隐藏基类函数:如果需要不同实现,要么用重写(基类加virtual),要么换个函数名。

总结

函数重载是“编译期的同名接口简化”,靠名字修饰实现;函数重写是“运行期的多态实现”,靠虚函数表机制支撑,让同一接口能适配不同子类的实现——搞懂这两个特性,你对C++的理解会再上一个台阶!

完结,撒花~~

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐