【C++】17. 多态

news2025/5/15 21:52:14

上一章节中我们讲了C++三大特性的继承,这一章节我们接着来讲另一个特性——多态

1. 多态的概念

多态(polymorphism)的概念:通俗来说,就是多种形态。多态分为编译时多态(静态多态)和运行时多态(动态多态),这里我们重点讲运行时多态。编译时多态(静态多态)主要就是我们前面讲的函数重载和函数模板,他们传不同类型的参数就可以调用不同的函数,通过参数不同达到多种形态,之所以叫编译时多态,是因为他们实参传给形参的参数匹配是在编译时完成的,我们把编译时一般归为静态,运行时归为动态。

运行时多态,具体点就是去完成某个行为(函数),可以传不同的对象就会完成不同的行为,就达到多种形态。比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是优惠买票(5折或75折);军人买票时是优先买票。再比如,同样是动物叫的一个行为(函数),传猫对象过去,就是”(>^ω^<)喵“,传狗对象过去,就是"汪汪"。


2. 多态的定义及实现

2.1 多态的构成条件

多态是一个继承关系下的类对象,去调用同一函数,产生了不同的行为。比如Student继承了

Person。Person对象买票全价,Student对象优惠买票。

2.1.1 实现多态还有两个必须重要条件:

必须基类指针或者引用调用虚函数

被调用的函数必须是虚函数。

说明:要实现多态效果,第一必须是基类的指针或引用,因为只有基类的指针或引用才能既指向派生类对象;第二派生类必须对基类的虚函数重写/覆盖,重写或者覆盖了,派生类才能有不同的函数,多态的不同形态效果才能达到。


2.1.2 虚函数

类成员函数前面加virtual修饰,那么这个成员函数被称为虚函数。注意非成员函数不能加virtual修 饰,至于为什么?我们下面再给出解释

class Person
{
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
};

2.1.3 虚函数的重写/覆盖

重写(Override) 是实现运行时多态(Runtime Polymorphism)的核心机制之一。它允许子类(派生类)重新定义父类(基类)中已有的虚函数(virtual函数),从而在调用时根据对象的实际类型动态选择执行哪个函数。

重写的条件

  • 派生类中的函数必须与基类虚函数具有相同的函数名、参数列表和返回类型(协变返回类型除外,见下文)。

  • 基类函数必须是 virtual 的(除非是接口中的纯虚函数)。

  • 派生类的函数访问权限可以不同(如基类为 protected,派生类可为 public)。

总结一下就是:

虚函数的重写/覆盖:派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称派生类的虚函数重写了基类的虚函数。

注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用,不过在考试选择题中,经常会故意埋这个坑,让你判断是否构成多态。

重写的本质:子类通过重新定义基类虚函数,实现多态行为。

示例1-买票:

class Person 
{
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person 
{
public:
	virtual void BuyTicket() { cout << "买票-打折" << endl; }
};
void Func(Person* ptr)
{
	// 这里可以看到虽然都是Person指针Ptr在调用BuyTicket
	// 但是跟ptr没关系,而是由ptr指向的对象决定的。
	ptr->BuyTicket();
}
int main()
{
	Person ps;
	Student st;
	Func(&ps);
	Func(&st);
	return 0;
}

示例2-动物叫:

class Animal
{
public:
	virtual void talk() const
	{}
};
class Dog : public Animal
{
public:
	virtual void talk() const
	{
		std::cout << "汪汪" << std::endl;
	}
};
class Cat : public Animal
{
public:
	virtual void talk() const
	{
		std::cout << "(>^ω^<)喵" << std::endl;
	}
};
void letsHear(const Animal& animal)
{
	animal.talk();
}
int main()
{
	Cat cat;
	Dog dog;
	letsHear(cat);
	letsHear(dog);
	return 0;
}

上面我们分别演示了基类的指针和引用来实现多态的效果,至于为什么和他多态的原理我们下文会讲。


2.1.4 多态场景的一个选择题

以下程序输出结果是什么()

A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确

class A
{
public:
	virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
	virtual void test() { func(); }
};
class B : public A
{
public:
	void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};
int main(int argc, char* argv[])
{
	B* p = new B;
	p->test();
	return 0;
}

解析:

  1. 虚函数调用机制

    • test() 是基类 A 的虚函数,虽然被 B继承,但未被 B 重写,因此 p->test() 调用的是 A::test()

    • 在 A::test() 中,func() 的调用触发动态绑定。由于 p 指向 B 对象,实际调用的是 B::func(int val)

  2. 默认参数的静态绑定

    • 默认参数的值在编译时根据调用者的静态类型确定。

    • A::test() 中调用 func() 时,静态类型是 A*(A类中this指针,即是A*),因此默认参数使用 A::func(int val = 1) 中的 1

    • 动态调用的是 B::func(int val),但参数值已确定为 1

  3. 最终输出逻辑

    • B::func(int val) 接收参数 1,输出 B->1

需要注意的是我们重写是派生类重写基类虚函数的定义,也就是重写基类虚函数的实现

误解:默认参数随虚函数动态绑定。
正解:默认参数是静态绑定的,仅依赖调用表达式的静态类型,与虚函数的动态分派无关。

运行结果:


2.1.5 虚函数重写的一些其他问题

协变(了解)

派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。协变的实际意义并不大,所以我们了解一下即可。

class A {};
class B : public A {};
class Person {
public:
	virtual A* BuyTicket()
	{
		cout << "买票-全价" << endl;
		return nullptr;
	}
};
class Student : public Person {
public:
	virtual B* BuyTicket()
	{
		cout << "买票-打折" << endl;
		return nullptr;
	}
};
void Func(Person* ptr)
{
	ptr->BuyTicket();
}
int main()
{
	Person ps;
	Student st;
	Func(&ps);
	Func(&st);
	return 0;
}

析构函数的重写

基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor,所以基类的析构函数加了vialtual修饰,派生类的析构函数就构成重写。

下面的代码我们可以看到,如果~A(),不加virtual,那么delete p2时只调用A的析构函数,没有调用B的析构函数,就会导致内存泄漏问题,因为~B()中在释放资源。

注意:这个问题面试中经常考察,大家一定要结合类似下面的样例才能讲清楚,为什么基类中的析构函数建议设计为虚函数。

class A
{
public:
	virtual ~A()
	{
		cout << "~A()" << endl;
	}
};
class B : public A {
public:
	~B()
	{
		cout << "~B()->delete:" << _p << endl;
		delete _p;
	}
protected:
	int* _p = new int[10];
};
// 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函数,才能
// 构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
int main()
{
	A* p1 = new A;
	A* p2 = new B;
	delete p1;
	delete p2;
	return 0;
}

1.为什么允许名称不同仍能重写?

(1)语言设计的必要性

  • 资源释放的正确性
    C++需要确保派生类对象销毁时,先调用派生类析构函数释放派生类资源,再调用基类析构函数释放基类资源。若不允许名称不同重写,多态销毁将无法实现,导致资源泄漏。

  • 统一销毁接口
    所有析构函数的调用最终通过 delete 运算符触发,而 delete 的语法是统一的(如 delete ptr;)。虚析构函数机制使得无论对象的实际类型如何,都能正确调用完整的析构链。

(2)底层实现的支持

  • 析构函数的特殊标识
    编译器在内部将析构函数视为一种特殊的虚函数,其名称差异在编译阶段被隐藏。虚函数表中会为析构函数保留专用槽位,确保动态绑定。

  • 析构函数链的自动调用
    即使派生类析构函数名称不同,编译器也会在派生类析构函数末尾自动插入基类析构函数的调用代码(类似于 Base::~Base();)。


2. 与普通虚函数重写的区别

特性普通虚函数重写析构函数重写
函数名必须相同允许不同(如 ~Base() 和 ~Derived()
返回类型必须相同(协变返回类型例外)固定为 void,无需关心返回类型
参数列表必须相同无参数,无需匹配
调用顺序由代码显式调用决定由继承链隐式控制(派生类→基类)

2.1.6 override 和 final关键字

从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,比如函数名写错参数写错等导致无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此C++11提供了override,可以帮助用户检测是否重写。如果我们不想让派生类重写这个虚函数,那么可以用final去修饰。

// error C3668: “Benz::Drive”: 包含重写说明符“override”的方法没有重写任何基类方法
class Car {
public:
	virtual void Dirve()
	{}
};
class Benz :public Car {
public:
	virtual void Drive() override { cout << "Benz-舒适" << endl; }
};
int main()
{
	return 0;
}

// error C3248: “Car::Drive”: 声明为“final”的函数无法被“Benz::Drive”重写
class Car
{
public:
	virtual void Drive() final {}
};
class Benz :public Car
{
public:
	virtual void Drive() { cout << "Benz-舒适" << endl; }
};
int main()
{
	return 0;
}


2.1.7 重载/重写/隐藏的对比

1. 核心定义

概念描述
重载在同一作用域内,定义多个同名函数,但参数列表不同(参数类型、顺序、数量)。
重写派生类中重新定义基类的虚函数,函数签名(名称、参数、返回类型)必须一致。
隐藏派生类中定义与基类同名的函数(无论参数是否相同),导致基类同名函数被隐藏。

2. 关键特性对比

特性重载重写隐藏
作用域同一作用域(如类内或同一命名空间)基类和派生类之间(跨作用域)基类和派生类之间(跨作用域)
函数签名必须不同(参数列表不同)必须相同(协变返回类型除外)可以相同或不同
多态性静态多态(编译时决定调用哪个函数)动态多态(运行时根据对象实际类型决定)静态多态(根据调用者的静态类型决定)
virtual不要求基类函数必须声明为 virtual不要求
访问权限不影响重载派生类函数的访问权限可以不同于基类不影响隐藏
典型场景提供同一功能的多种实现方式实现多态,扩展基类行为派生类无意中屏蔽基类函数

3. 代码示例

(1) 重载(Overload)

class Calculator {
public:
    int add(int a, int b) { return a + b; }
    double add(double a, double b) { return a + b; } // 重载:参数类型不同
};

(2) 重写(Override)

class Base {
public:
    virtual void print() { cout << "Base" << endl; }
};

class Derived : public Base {
public:
    void print() override { cout << "Derived" << endl; } // 重写:函数签名一致
};

(3) 隐藏(Hiding)

class Base {
public:
    void func(int x) { cout << "Base::func(int)" << endl; }
};

class Derived : public Base {
public:
    void func(double x) { cout << "Derived::func(double)" << endl; } // 隐藏Base::func(int)
};

// 使用示例
Derived d;
d.func(5); // 调用 Derived::func(double),而非 Base::func(int)

4. 常见问题与陷阱

陷阱1:隐藏导致意外行为

class Base {
public:
    void show() { cout << "Base" << endl; }
};

class Derived : public Base {
public:
    void show(int x) { cout << "Derived" << endl; } // 隐藏 Base::show()
};

Derived d;
d.show(); // 编译错误!Base::show() 被隐藏,需通过 d.Base::show() 调用

陷阱2:重写与隐藏的混淆

class Base {
public:
    virtual void foo(int x) { /* ... */ }
};

class Derived : public Base {
public:
    void foo(double x) { /* ... */ } // 隐藏 Base::foo(int),非重写!
};

5. 总结对比表

行为重载重写隐藏
作用域同一作用域基类与派生类基类与派生类
函数签名不同相同可同可异
多态性静态绑定(编译时)动态绑定(运行时)静态绑定(编译时)
virtual无关必须无关
目的扩展功能接口实现多态行为无意或有意屏蔽基类函数

6. 最佳实践

  1. 优先使用重载:同一功能的不同参数形式。

  2. 明确使用重写:基类虚函数标记 virtual,派生类使用 override 关键字(C++11+)。

  3. 避免意外隐藏:在派生类中使用 using Base::func; 显式引入基类函数:

    class Derived : public Base {
    public:
        using Base::func; // 引入 Base::func(int)
        void func(double x) { /* ... */ }
    };

结论

  • 重载:同一作用域,参数不同,静态绑定。

  • 重写:跨作用域,虚函数签名一致,动态绑定。

  • 隐藏:跨作用域,同名函数屏蔽基类函数,静态绑定。
    正确理解三者差异,是编写高效、安全C++代码的基础。


3. 纯虚函数和抽象类

在虚函数的后面写上 =0 ,则这个函数为纯虚函数,纯虚函数不需要定义实现(实现没啥意义因为要被派生类重写,但是语法上可以实现),只要声明即可。包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派生类继承后不重写纯虚函数,那么派生类也是抽象类。纯虚函数某种程度上强制了派生类重写虚函数,因为不重写实例化不出对象。

class Car
{
public:
	virtual void Drive() = 0;
};
class Benz :public Car
{
public:
	virtual void Drive()
	{
		cout << "Benz-舒适" << endl;
	}
};
class BMW :public Car
{
public:
	virtual void Drive()
	{
		cout << "BMW-操控" << endl;
	}
};

使用抽象类会编译报错

int main()
{
	// 编译报错:error C2259: “Car”: 无法实例化抽象类
	Car car;
	return 0;
}

int main()
{
	// 编译报错:error C2259: “Car”: 无法实例化抽象类
	//Car car;
	Car* pBenz = new Benz;
	pBenz->Drive();
	Car* pBMW = new BMW;
	pBMW->Drive();
	return 0;
}


纯虚函数的默认实现

纯虚函数不需要定义实现,但是可以定义默认实现,需在类外定义,但派生类仍需显式重写:

class Logger {
public:
    virtual void log(const std::string& msg) = 0;
};

// 类外提供默认实现
void Logger::log(const std::string& msg) {
    std::cout << "[Default] " << msg << std::endl;
}

class FileLogger : public Logger {
public:
    void log(const std::string& msg) override {
        Logger::log(msg); // 调用默认实现
        // 添加文件写入逻辑
    }
};

抽象类的设计价值

场景作用
定义接口规范强制派生类实现特定功能(如 area() 对所有几何形状是必需的)。
多态的基础通过基类指针或引用操作派生类对象,实现运行时多态。
代码复用与扩展抽象类可提供公共实现(如通用算法),派生类只需实现差异化逻辑。
解耦接口与实现用户代码依赖抽象接口,而非具体类,提高系统灵活性。

抽象类 vs 普通类

特性抽象类普通类
实例化不能直接创建对象可以直接实例化
纯虚函数必须包含至少一个纯虚函数无需纯虚函数
用途定义接口,强制派生类实现多态行为提供具体实现,可直接使用

4. 多态的原理

4.1 虚函数表指针

下面编译为32位程序的运行结果是什么()

A. 编译报错 B. 运行报错 C. 8 D. 12

class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
protected:
	int _b = 1;
	char _ch = 'x';
};
int main()
{
	Base b;
	cout << sizeof(b) << endl;
	return 0;
}

解析:

首先我们来看成员变量,一个int,4个字节,一个char,1个字节,然后补齐到8个字节,所以答案是C吗?

我们运行来看一下(注意是32位程序)

为什么是12呢?

除了_b和_ch成员,还多一个_vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中至少都有一个虚函数表指针,因为一个类中所有虚函数的地址要被放到这个类对象的虚函数表中,虚函数表也简称虚表。


4.2 多态的原理

4.2.1 多态是如何实现的

从底层的角度Func函数中ptr->BuyTicket(),是如何作为ptr指向Person对象调用Person::BuyTicket,ptr指向Student对象调用Student::BuyTicket的呢?通过下图我们可以看到,满足多态条件后,底层不再是编译时通过调用对象确定函数的地址,而是运行时到指向的对象的虚表中确定对应的虚函数的地址,这样就实现了指针或引用指向基类就调用基类的虚函数,指向派生类就调用派生类对应的虚函数。第一张图,ptr指向的Person对象,调用的是Person的虚函数;第二张图,ptr指向的Student对象,调用的是Student的虚函数。

class Person {
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
	virtual void BuyTicket() { cout << "买票-打折" << endl; }
};
class Soldier : public Person {
public:
	virtual void BuyTicket() { cout << "买票-优先" << endl; }
};
void Func(Person* ptr)
{
	// 这里可以看到虽然都是Person指针Ptr在调用BuyTicket
	// 但是跟ptr没关系,而是由ptr指向的对象决定的。
	ptr->BuyTicket();
}
int main()
{
	// 其次多态不仅仅发生在派生类对象之间,多个派生类继承基类,重写虚函数后
	// 多态也会发生在多个派生类之间。
	Person ps;
	Student st;
	Soldier sr;
	Func(&ps);
	Func(&st);
	Func(&sr);
	return 0;
}

4.2.2 动态绑定与静态绑定

对不满足多态条件(指针或者引用+调用虚函数)的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定。

满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调用函数的地址,也就做动态绑定。

// ptr是指针+BuyTicket是虚函数满足多态条件。
// 这里就是动态绑定,编译在运行时到ptr指向对象的虚函数表中确定调用函数地址
ptr->BuyTicket();
00EF2001 mov eax, dword ptr[ptr]
00EF2004 mov edx, dword ptr[eax]
00EF2006 mov esi, esp
00EF2008 mov ecx, dword ptr[ptr]
00EF200B mov eax, dword ptr[edx]
00EF200D call eax
// BuyTicket不是虚函数,不满足多态条件。
// 这里就是静态绑定,编译器直接确定调用函数地址
ptr->BuyTicket();
00EA2C91 mov ecx, dword ptr[ptr]
00EA2C94 call Student::Student(0EA153Ch)

4.2.3 虚函数表

class Base {
public:
	virtual void func1() { cout << "Base::func1" << endl; }
	virtual void func2() { cout << "Base::func2" << endl; }
	void func5() { cout << "Base::func5" << endl; }
protected:
	int a = 1;
};
class Derive : public Base
{
public:
	// 重写基类的func1
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func1" << endl; }
	void func4() { cout << "Derive::func4" << endl; }
protected:
	int b = 2;
};

基类对象的虚函数表中存放基类所有虚函数的地址。

同类型的对象虚表共用,如果每个对象都各自一份,那就会冗余。但是不同类型虚表各自独立


派生类由两部分构成,继承下来的基类和自己的成员,一般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但是要注意的这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同一个,就像基类对象的成员和派生类对象中的基类对象成员也独立的。

派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址。

基类和派生类的虚表不是同一个,就像基类的成员a,虽然被派生类继承下来,但是两者只是值一样,都有自己的空间。

可以看到派生类中重写的虚函数func1的地址和基类的虚函数func1的地址不一样,说明派生类重写基类的虚函数之后,在派生类的虚表中对应的虚函数就会被覆盖成派生类重写的虚函数地址。也就是说重写之后,派生类自己的虚表存着他自己的那份虚函数


派生类的虚函数表中包含,基类的虚函数地址,派生类重写的虚函数地址,派生类自己的虚函数地址三个部分。

虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个0x00000000标记。(这个C++并没有进行规定,各个编译器自行定义的,vs系列编译器会再后面放个0x00000000标记,g++系列编译不会放)

可以看到派生类的虚表中存放着基类的虚函数func2的地址,派生类重写的虚函数func1的地址,这里Derive中没有看到func3函数,这个vs监视窗口看不到,可以通过内存窗口查看

再结合内存窗口,可以看到派生类自己的虚函数func3的地址,以及vs系列编译器会在虚函数表后面放个0x00000000标记


虚函数存在哪的?虚函数和普通函数一样的,编译好后是一段指令,都是存在代码段的,只是虚函数的地址又存到了虚表中。

虚函数表存在哪的?这个问题严格说并没有标准答案C++标准并没有规定,我们写下面的代码可以对比验证一下。vs下是存在代码段(常量区)

class Base {
public:
	virtual void func1() { cout << "Base::func1" << endl; }
	virtual void func2() { cout << "Base::func2" << endl; }
	void func5() { cout << "Base::func5" << endl; }
protected:
	int a = 1;
};
class Derive : public Base
{
public:
	// 重写基类的func1
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func1" << endl; }
	void func4() { cout << "Derive::func4" << endl; }
protected:
	int b = 2;
};
int main()
{
	int i = 0;
	static int j = 1;
	int* p1 = new int;
	const char* p2 = "xxxxxxxx";
	printf("栈:%p\n", &i);
	printf("静态区:%p\n", &j);
	printf("堆:%p\n", p1);
	printf("常量区:%p\n", p2);
	Base b;
	Derive d;
	Base* p3 = &b;
	Derive* p4 = &d;
	printf("Person虚表地址:%p\n", *(int*)p3);
	printf("Student虚表地址:%p\n", *(int*)p4);
	printf("虚函数地址:%p\n", &Base::func1);
	printf("普通函数地址:%p\n", &Base::func5);
	return 0;
}

为什么要把Base*的p3和Derive*的p4强转成int*呢?

1. 强制转换 int* 的原理

(1) 对象内存布局

  • 对于包含虚函数的类,每个对象的内存起始位置存储一个指向虚表的指针 vptr,随后是类的成员变量。

  • 内存布局示意图:

    | vptr (4/8字节) | a (4字节) | ... |

(2) 强制转换的意义

  • Base* p3 = &b; 的指针类型是 Base*,但我们需要访问 vptr(即内存起始的地址)。

  • 通过 *(int*)p3

    • 先将 Base* 转换为 int*,此时指针指向对象内存的首地址(即 vptr 的位置)。

    • 解引用 int* 得到 vptr 的值(即虚表的地址)。

(3) 代码示例分析

Base* p3 = &b;
printf("Person虚表地址:%p\n", *(int*)p3);
  • 步骤分解

    1. p3 指向 Base 对象 b 的起始地址(即 vptr 的位置)。

    2. 将 Base* 强转为 int*,此时 int* 指向 vptr

    3. 解引用 int* 得到 vptr 的值(即虚表的地址)。


2. 为什么必须是 int*

  • 指针类型与内存对齐
    虚表指针 vptr 的大小与 int* 相同(在32位系统为4字节,64位系统为8字节)。
    将指针转换为 int* 可以正确解析 vptr 的二进制内容。

  • 通用性
    int* 是一种通用的指针类型,可以兼容不同平台下指针的存储方式。

运行结果:

可以看到虚表地址和常量区的地址是非常接近的,虚函数和普通函数的地址也是非常接近的状态


不过可能有人会有这样的问题:为什么多态要使用基类的指针或引用而不能使用对象呢?

1. 对象切片(Object Slicing)问题

当派生类对象直接赋值给基类对象时,派生类特有的成员会被“切掉”,仅保留基类部分。此时,基类对象无法访问派生类的虚函数,导致多态失效。

示例

class Base {
public:
    virtual void print() { cout << "Base" << endl; }
};

class Derived : public Base {
public:
    void print() override { cout << "Derived" << endl; }
};

int main() {
    Derived d;
    Base b = d;  // 对象切片:b 是 Base 类型,无法保留 Derived 的信息
    b.print();   // 输出 "Base"(多态失效)
    return 0;
}

关键问题

  • 值传递的拷贝行为:直接操作对象会触发拷贝构造函数或赋值运算符,生成一个基类对象副本。

  • 虚函数表丢失:基类对象副本的虚表指针(vptr)指向基类的虚表,而非派生类。


2. 动态绑定的依赖条件

动态绑定(运行时多态)要求通过指针或引用访问对象,以保留对象的完整类型信息。

指针/引用的行为

Base* ptr = new Derived();  // 指针保留对象的动态类型
Base& ref = *ptr;           // 引用同理
ptr->print();               // 输出 "Derived"(动态绑定成功)
  • 内存布局保留:指针或引用直接操作原始对象的内存,包括派生类的虚表指针(vptr)。

  • 虚表查找机制:调用虚函数时,通过 vptr 找到派生类的虚表,执行正确的函数。

直接使用对象的局限性

void func(Base obj) { obj.print(); }  // 值传递导致对象切片

Derived d;
func(d);  // 输出 "Base"(派生类信息丢失)
  • 静态类型绑定:对象类型在编译时确定,无法根据实际类型动态分派。


3. C++语言规则的限制

根据C++标准,动态绑定仅适用于通过指针或引用调用虚函数,直接使用对象时虚函数调用是静态绑定的。

规则总结

调用方式绑定类型多态行为
Base obj = derived; obj.func();静态绑定(编译时)
Base* ptr = &derived; ptr->func();动态绑定(运行时)
Base& ref = derived; ref.func();动态绑定(运行时)

4. 内存模型的本质差异

对象的内存布局

  • 直接对象:内存中仅包含基类成员和基类虚表指针(vptr)。

  • 指针/引用:指向完整的派生类对象内存(包括派生类成员和派生类虚表指针)。

示例分析

Derived d;
Base b1 = d;     // 对象切片:b1 是 Base 类型,vptr 指向 Base 的虚表
Base* b2 = &d;   // b2 指向 Derived 对象,vptr 指向 Derived 的虚表

5. 解决方案与最佳实践

避免对象切片

  • 使用指针或引用传递对象

    void func(Base& obj) { obj.print(); }  // 通过引用传递
    Derived d;
    func(d);  // 输出 "Derived"
  • 使用智能指针

    std::unique_ptr<Base> ptr = std::make_unique<Derived>();
    ptr->print();  // 输出 "Derived"

    后面的章节会做详细的讲解

明确多态的适用范围

  • 基类析构函数必须为虚函数:确保通过基类指针删除派生类对象时,正确调用派生类析构函数。

  • 优先使用 override 关键字:明确派生类函数的重写意图,避免隐藏或错误重写。


总结

  • 对象切片的根本原因:值传递导致派生类信息丢失,虚表指针无法指向派生类。

  • 动态绑定的必要条件:必须通过指针或引用保留对象的完整内存布局(包括虚表指针)。

  • 语言规则限制:C++标准规定动态绑定仅支持指针或引用调用虚函数。


上面我们还提到了一个问题:为什么非成员函数不能加virtual修饰

我们下面给出回答

1. 语言设计的本质约束

  • 虚函数依赖对象上下文
    虚函数的调用依赖于对象的动态类型(通过虚函数表vtable实现)。而非成员函数没有隐含的this指针,无法绑定到具体对象的上下文,因此无法实现动态分派。

  • 作用域归属问题
    虚函数必须是类的成员函数,因为它需要属于某个类的接口。非成员函数不属于任何类,自然无法参与类的继承体系。


2. 技术实现的不可行性

  • 虚函数表(vtable)的绑定机制
    每个含有虚函数的类会生成一个虚函数表,其中存储了指向虚函数的指针。非成员函数无法被添加到类的虚函数表中,因为它们没有与类实例关联的this指针。

  • 对象内存布局的缺失
    非成员函数无法访问对象的成员变量,而虚函数通常需要操作对象内部状态。若允许非成员函数为虚,其实现将无法与对象的内存布局兼容。


3. 语义矛盾

  • 多态与封装的关系
    虚函数是面向对象中封装和多态的核心机制,而非成员函数通常是过程式编程的产物。若允许非成员函数为虚,会破坏C++对“对象行为”的封装性设计。

  • 重写(override)的不可操作性
    虚函数的核心目的是允许派生类重写基类行为,而非成员函数没有所属类,无法在派生类中通过继承机制重写。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2376385.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

家用或办公 Windows 电脑玩人工智能开源项目配备核显的必要性(含 NPU 及显卡类型补充)

一、GPU 与显卡的概念澄清 首先需要明确一个容易误解的概念&#xff1a;GPU 不等同于显卡。 显卡和GPU是两个不同的概念。 【概念区分】 在讨论图形计算领域时&#xff0c;需首先澄清一个常见误区&#xff1a;GPU&#xff08;图形处理单元&#xff09;与显卡&#xff08;视…

实现一个简单的 TCP 客户端/服务器

注意&#xff1a; TCP 三次握手建立连接建立连接后&#xff0c;TCP 提供全双工的通信服务&#xff0c;也就是在同一个连接中&#xff0c;通信双方 可以在同一时刻同时写数据&#xff0c;相对的概念叫做半双工&#xff0c;同一个连接的同一时刻&#xff0c;只能由一方来写数据T…

对抗帕金森:在疾病阴影下,如何重掌生活主动权?

帕金森病&#xff0c;一种影响全球超 1000 万人的神经退行性疾病&#xff0c;正无声地改变着患者的生活轨迹。随着大脑中多巴胺分泌减少&#xff0c;患者逐渐出现肢体震颤、肌肉僵硬、步态迟缓等症状&#xff0c;甚至连扣纽扣、端水杯这类日常动作都变得艰难。更棘手的是&#…

鸿蒙 UIAbility组件与UI的数据同步和窗口关闭

使用 EventHub 进行数据通信 Stage模型概念图 根据 Stage 模型概念图 UIAbility 先于 ArkUI Page 创建 所以&#xff0c;事件要先 .on 订阅 再 emit 发布 假如现在有页面 Page1 和他的 UIAbility // src/main/ets/page1ability/Page1Ability.ets onCreate(want: Want, laun…

Vue3学习(组合式API——计算属性computed详解)

目录 一、计算属性computed。 Vue官方提供的案例。(普通写法与计算属性写法) 使用计算属性computed重构——>简化描述响应式状态的复杂逻辑。 &#xff08;1&#xff09;计算属性computed小案例。 <1>需求说明。&#xff08;筛选原数组——>得新数组&#xff09; &…

Android Studio 模拟器配置方案

Android Studio 模拟器配置方案 1.引言2.使用Android Studio中的模拟器3.使用国产模拟器1.引言 前面介绍【React Native基础环境配置】的时候需要配置模拟器,当时直接使用了USB调试方案,但是有些时候可能不太方便连接手机调试,比如没有iPhone调不了ios。接下来说明另外两种可…

k8s中ingress-nginx介绍

1. 介绍 Ingress是一种Kubernetes资源&#xff0c;用于将外部流量路由到Kubernetes集群内的服务。与NodePort相比&#xff0c;它提供了更高级别的路由功能和负载平衡&#xff0c;可以根据HTTP请求的路径、主机名、HTTP方法等来路由流量。可以说Ingress是为了弥补NodePort在流量…

字节DeerFlow开源框架:多智能体深度研究框架,实现端到端自动化研究流程

&#x1f98c; DeerFlow DeerFlow&#xff08;Deep Exploration and Efficient Research Flow&#xff09;是一个社区驱动的深度研究框架&#xff0c;它建立在开源社区的杰出工作基础之上。目标是将语言模型与专业工具&#xff08;如网络搜索、爬虫和Python代码执行&#xff0…

算法第十八天|530. 二叉搜索树的最小绝对差、501.二叉搜索树中的众数、236. 二叉树的最近公共祖先

530. 二叉搜索树的最小绝对差 题目 思路与解法 第一想法&#xff1a; 一个二叉搜索树的最小绝对差&#xff0c;从根结点看&#xff0c;它的结点与它的最小差值一定出现在 左子树的最右结点&#xff08;左子树最大值&#xff09;和右子树的最左结点&#xff08;右子树的最小值…

微服务调试问题总结

本地环境调试。 启动本地微服务&#xff0c;使用公共nacos配置。利用如apifox进行本地代码调试解决调试问题。除必要的业务微服务依赖包需要下载到本地。使用mvn clean install -DskipTests进行安装启动前选择好profile环境进行启动&#xff0c;启动前记得mvn clean清理项目。…

美SEC主席:探索比特币上市证券交易所

作者/演讲者&#xff1a;美SEC主席Paul S. Atkins 编译&#xff1a;Liam 5月12日&#xff0c;由美国SEC加密货币特别工作组发起的主题为《资产上链&#xff1a;TradFi与DeFi的交汇点》系列圆桌会议如期举行。 会议期间&#xff0c;现任美SEC主席Paul S. Atkins发表了主旨演讲。…

MySQL Join连接算法深入解析

引言 在关系型数据库中&#xff0c;Join操作是实现多表数据关联查询的关键手段&#xff0c;直接影响查询性能和资源消耗。MySQL支持多种Join算法&#xff0c;包括经典的索引嵌套循环连接&#xff08;Index Nested-Loop Join&#xff09;、块嵌套循环连接&#xff08;Block Nes…

http请求卡顿

接口有时出现卡顿&#xff0c;而且抓包显示有时tcp目标机器没有响应&#xff0c; 但nginx和java应用又没有错误日志&#xff0c;让人抓耳挠腮&#xff0c;最终还是请运维大哥帮忙&#xff0c;一顿操作后系统暂时无卡顿了&#xff0c;佩服的同时感觉疑惑到底调整了啥东…

vite+vue建立前端工程

​ 参考 开始 | Vite 官方中文文档 VUE教程地址 https://cn.vuejs.org/tutorial/#step-1 第一个工程 https://blog.csdn.net/qq_35221977/article/details/137171497 脚本 chcp 65001 echo 建立vite工程 set PRO_NAMEmy-vue-appif not exist %PRO_NAME% (call npm i…

vue使用路由技术实现登录成功后跳转到首页

文章目录 一、概述二、使用步骤安装vue-router在src/router/index.js中创建路由器&#xff0c;并导出在vue应用实例中使用router声明router-view标签&#xff0c;展示组件内容 三、配置登录成功后跳转首页四、参考资料 一、概述 路由&#xff0c;决定从起点到终点的路径的进程…

day20-线性表(链表II)

一、调试器 1.1 gdb&#xff08;调试器&#xff09; 在程序指定位置停顿 1.1.1 一般调试 gcc直接编译生成的是发布版&#xff08;Release&#xff09; gcc -g //-g调式版本&#xff0c;&#xff08;体积大&#xff0c;内部有源码&#xff09;&#xff08;DeBug&#…

HTTP 连接复用机制详解

文章目录 HTTP 连接复用机制详解为什么需要连接复用&#xff1f;连接复用的实现方式HTTP/1.1 的 Keep-AliveHTTP/2 多路复用 HTTP/1.1 的队头阻塞问题 HTTP 连接复用机制详解 HTTP 连接复用是 HTTP/1.1 及更高版本中的核心优化机制&#xff0c;旨在减少 TCP 连接建立和关闭的开…

网络协议分析 实验六 TCP和端口扫描

文章目录 实验6.1 TCP(Transfer Control Protocol)练习二 利用仿真编辑器编辑并发送TCP数据包实验6.2 UDP端口扫描实验6.3 TCP端口扫描练习一 TCP SYN扫描练习二 TCP FIN扫描 实验6.1 TCP(Transfer Control Protocol) 建立&#xff1a;syn,syn ack,ack 数据传送&#xff1a;tcp…

Spring Web MVC————入门(2)

1&#xff0c;请求 我们接下来继续讲请求的部分&#xff0c;上期将过很多了&#xff0c;我们来给请求收个尾。 还记得Cookie和Seesion吗&#xff0c;我们在HTTP讲请求和响应报文的时候讲过&#xff0c;现在再给大家讲一遍&#xff0c;我们HTTP是无状态的协议&#xff0c;这次的…

每日算法-250514

每日算法学习记录 (2024-05-14) 今天记录三道 LeetCode 算法题的解题思路和代码。 1. 两数之和 题目截图: 解题思路 这道题要求我们从一个整数数组中找出两个数&#xff0c;使它们的和等于一个给定的目标值 target&#xff0c;并返回这两个数的下标。 核心思路是使用 哈希…