C++面向对象
什么是面向对象?面向对象的三大特性
面向对象:对象是指具体的某一个事物,这些事物的抽象就是类,类中包含数据(成员变量)和动作(成员方法)。
面向对象的三大特性:
- 封装:将具体的实现过程和数据封装成一个函数,只能通过接口进行访问,降低耦合性。
- 继承:子类继承父类的特征和行为,子类有父类的非 private 方法或成员变量,子类可以对父类的方法进行重写,增强了类之间的耦合性,但是当父类中的成员变量、成员函数或者类本身被 final 关键字修饰时,修饰的类不能继承,修饰的成员不能重写或修改。
- 多态:多态就是不同继承类的对象,对同一消息做出不同的响应,基类的指针指向或绑定到派生类的对象,使得基类指针呈现不同的表现方式。
C++的多态是什么?怎么通过虚函数实现?
C++中的多态通常分为两种主要类型:
- 编译时多态(静态多态):通过函数重载和运算符重载实现,在编译时确定调用哪个函数。
- 运行时多态(动态多态):通过虚函数实现,在运行时根据对象的实际类型决定调用的函数。实现原理是,每个包含虚函数的类都有一个虚函数表,这个表记录了该类对象可以调用的虚函数指针,每个对象也有一个隐式的虚函数指针,指向其类的虚函数表。在运行时,调用虚函数时,程序通过虚函数指针查找正确的函数。
虚函数是通过在基类中声明一个函数为 virtual 来实现的。这标志着这个函数可以被派生类重写(override)。当通过基类指针或引用调用虚函数时,C++会查找实际对象的类型,调用对应的子类实现,而不是基类的实现。
多态的实现依赖于一个称为"动态绑定"或"运行时绑定"的机制。当基类中的函数被声明为 virtual 时,编译器就会为这个类开启多态支持。
- 声明虚函数 :在基类中,使用
virtual关键字修饰一个成员函数。 - 重写虚函数 :在派生类中,提供一个与基类虚函数签名完全相同 (函数名、参数列表、常量性都一致)的函数。从 C++11 开始,强烈建议使用
override关键字来明确表示这是在重写基类虚函数,这能帮助编译器检查错误。 - 通过指针/引用调用 :当通过基类的指针或引用去调用这个虚函数时,程序不会在编译时就决定调用哪个函数,而是会在运行时检查指针或引用实际指向的对象类型,然后调用该对象所属类的函数版本。
cpp
#include <iostream>
#include <vector>
#include <string>
// 1. 定义一个基类,并声明虚函数
class Animal {
public:
virtual void Speak() const {
std::cout << "Some animal sound" << std::endl;
}
virtual ~Animal() = default; // 虚析构函数是好习惯
};
// 2. 派生类重写虚函数
class Dog : public Animal {
public:
void Speak() const override { // 使用 override 关键字
std::cout << "Woof! Woof!" << std::endl;
}
};
class Cat : public Animal {
public:
void Speak() const override {
std::cout << "Meow!" << std::endl;
}
};
// 一个使用多态的函数
void MakeAnimalSpeak(const Animal& animal) {
// 在这里,我们只知道它是一个 Animal,
// 但它会根据传入对象的实际类型,调用正确的 Speak() 函数。
animal.Speak();
}
int main() {
Dog my_dog;
Cat my_cat;
// 调用函数,传入不同类型的对象
MakeAnimalSpeak(my_dog); // 输出: Woof! Woof!
MakeAnimalSpeak(my_cat); // 输出: Meow!
// 使用基类指针和容器来管理不同对象
std::vector<Animal*> zoo;
zoo.push_back(&my_dog);
zoo.push_back(&my_cat);
std::cout << "\n--- The Zoo is speaking ---\n";
for (const auto& animal_ptr : zoo) {
animal_ptr->Speak(); // 动态绑定,调用各自版本的 Speak()
}
return 0;
}
C++的多态底层原理:虚函数表 (vtable)
你可能会好奇,C++ 是如何在运行时知道该调用哪个函数的?这背后是编译器的一种实现机制,通常称为虚函数表(vtable) 和虚指针(vptr)。
- 虚函数表 (vtable):编译器会为每一个包含虚函数的类创建一个静态数组(即 vtable)。这个表里存储的是一系列函数指针,指向该类最新的虚函数实现。
- 虚指针 (vptr):编译器会在每个该类的对象中,悄悄插入一个指针(即 vptr)。这个指针在对象构造时,会被自动初始化,指向其所属类的 vtable。
当通过基类指针调用虚函数时,程序会:
- 通过对象的 vptr 找到它实际所属类的 vtable。
- 在 vtable 中查找对应函数的地址。
- 调用该地址指向的函数。
这个过程就实现了"动态绑定",确保了调用的是正确对象版本的函数。
C++中的多态怎么实现的?
C++中的多态主要通过虚函数和继承来实现。多态分为两种:编译时多态和运行时多态。
- 编译时多态:也称为静态多态或早绑定。这种多态是通过函数重载和模板来实现的。
- 运行时多态:也称为动态多态或晚绑定。这种多态是通过虚函数和继承来实现的。当基类的指针或引用指向派生类对象时,调用的虚函数将是派生类的版本,这就实现了运行时多态。
C++多态特性是什么?
C++ 的多态性指的是同一个操作作用于不同对象时,能产生不同的行为,通俗点说就是"一个接口,多种实现"。它主要分为编译时多态 (静态多态)和运行时多态(动态多态)两种。
编译时多态
这种多态是在编译阶段就确定下来的,效率很高,主要靠以下两种方式实现:
- 函数重载:在同一个作用域内,函数名相同但参数列表不同(参数个数、类型或顺序不同)。编译器会根据调用时传入的实参,在编译时就决定调用哪个具体的函数版本。
- 运算符重载 :允许我们重新定义已有的运算符(如
+,-,*,<<等),使其能用于用户自定义类型(如类对象)。这也是在编译时根据操作数的类型来确定具体调用的运算符函数。- 模板:虽然严格来说模板是一种泛型编程机制,但它也体现了静态多态的思想。编译器会根据模板参数实例化出具体的函数或类,这个过程发生在编译时。
运行时多态
这是 C++ 面向对象编程中最核心、最强大的多态形式,它允许程序在运行时根据对象的实际类型来调用相应的函数。
核心机制:
- 继承:必须有基类和派生类的继承关系。
- 虚函数 :在基类中使用
virtual关键字声明的函数。- 重写:派生类中必须提供一个与基类虚函数签名完全一致的函数实现。
- 基类指针/引用:必须通过基类的指针或引用来调用虚函数。
实现原理 :
这通常是通过虚函数表来实现的。
- 每个包含虚函数的类都有一个虚函数表,这是一个存放虚函数地址的数组。
- 该类的每个对象都包含一个隐藏的指针,指向这个虚函数表。
- 当通过基类指针调用虚函数时,程序会先找到对象的虚函数表指针,再从虚函数表中找到对应的函数地址进行调用。因为派生类对象的虚函数表里存放的是派生类的函数地址,所以实现了"调用派生类的版本"。
C++的函数对象是什么?跟普通函数的区别?
在 C++ 中,函数对象(Function Object) ,也被称为仿函数(Functor) ,是一个重载了函数调用运算符 operator() 的类或结构体。这使得它的实例可以像普通函数一样被调用。
函数对象是 C++ 标准模板库(STL)的核心组件之一,因为它比普通函数更灵活、更强大。
- 定义方式 :普通函数通过
返回类型 函数名(参数)的语法定义;函数对象是类/结构体,需重载operator()。 - 状态:普通函数无状态(每次调用独立);函数对象可通过成员变量保存状态(多次调用共享数据)。
- 调用方式:普通函数直接调用;函数对象需先创建实例,再用实例调用。
- 灵活性 :函数对象可重载多个
operator()或添加其他成员函数/变量,普通函数仅能定义单一函数逻辑。
| 特性 | 普通函数 | 函数对象 (仿函数) |
|---|---|---|
| 状态保持 | 无法保存状态,每次调用都是独立的 | 可以通过成员变量保存和修改状态,在多次调用之间共享数据 |
| 类型安全 | 作为参数传递时通常使用函数指针,类型检查较弱 | 是一个明确的类型,编译器可以在编译期进行严格的类型检查,更加安全 |
| 性能优化 | 通过函数指针调用,编译器难以进行内联优化。 | 编译器更容易识别并内联(inline)展开调用,从而提升性能,尤其是在频繁调用时source_group_web_8。 |
| 可适配性 | 与 STL 算法配合时,语法相对繁琐。 | 可以无缝集成到 STL 算法和容器中,作为自定义的比较器、谓词或操作符,语法简洁直观source_group_web_9。 |
cpp
#include <iostream>
class Counter {
private:
int count = 0; // 成员变量,用于保存状态
public:
int operator()() {
return ++count; // 每次调用,count 值都会增加
}
};
int main() {
Counter c;
std::cout << c() << ", " << c() << ", " << c() << std::endl;
// 输出: 1, 2, 3
return 0;
}
cpp
//普通函数:
int add(int a, int b) {
return a + b;
}
// 调用:int result = add(2, 3);
//函数对象:
class Add {
public:
int operator()(int a, int b) {
return a + b;
}
};
// 调用:Add addObj; int result = addObj(2, 3);
C++ 类相关
class中缺省的函数
在 C++ 中,如果一个类没有显式地定义构造函数、析构函数、拷贝构造函数、赋值运算符重载函数 ,编译器会自动为这个类生成这些函数,这些自动生成的函数被称为缺省函数 (也称为默认函数)。
详细说明
- 默认构造函数:当类没有显式定义任何构造函数时,编译器会生成一个无参的默认构造函数。它会初始化类的成员变量(对于内置类型,若未显式初始化则为随机值;对于类类型,会调用其默认构造函数)。
- 默认析构函数:若类没有显式定义析构函数,编译器会生成一个默认析构函数,负责释放类的成员(对于类类型成员,调用其析构函数;对于内置类型,无额外操作)。
- 默认拷贝构造函数 :当类没有显式定义拷贝构造函数时,编译器生成的默认拷贝构造函数会按成员进行浅拷贝(即逐个复制成员变量的值,对于指针成员,仅复制指针地址,而非动态分配的内存)。
- 默认赋值运算符重载函数 :若类没有显式定义赋值运算符,编译器生成的默认赋值运算符也会按成员进行浅拷贝赋值(逻辑与默认拷贝构造函数类似,处理指针成员时易引发内存泄漏或重复释放问题)。
什么是纯虚函数?有哪些应用场景
虚函数:被 virtual 关键字修饰的成员函数,就是虚函数。
纯虚函数是在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在C++中,纯虚函数的声明形式如下:
cppvirtual void function() = 0;其中,
= 0就表示这是一个纯虚函数。含有纯虚函数的类被称为抽象类。抽象类不能被实例化,只能作为接口使用。派生类必须实现所有的纯虚函数,否则该派生类也会变成抽象类。
纯虚函数的应用场景主要包括:
- 设计模式:例如在模板方法模式中,基类定义一个算法的骨架,而将一些步骤延迟到子类中。这些需要在子类中实现的步骤就可以声明为纯虚函数。
- 接口定义:可以创建一个只包含纯虚函数的抽象类作为接口。所有实现该接口的类都必须提供这些函数的实现。
虚函数必须实现,否则编译器会报错
在 C++ 中,虚函数(Virtual Function)并不强制要求必须实现(提供函数体),除非它是纯虚函数,或者被实际调用了。
为了准确理解,我们需要把情况分为三种来看:
普通虚函数
普通虚函数可以有实现,也可以没有实现(但在链接时如果被调用会报错)。
- 可以有实现:这是最常见的情况。基类提供一个默认实现,派生类可以选择重写(Override)它,也可以直接继承使用基类的实现。
- 可以没有实现 :你可以只声明
virtual void foo();而不写函数体。
- 注意 :如果代码中调用了这个没有实现的函数,链接器(Linker)会报错(unresolved external symbol)。
- 特殊情况:即使是纯虚函数,C++ 也允许提供实现(见下文)。
纯虚函数
纯虚函数(Pure Virtual Function)在声明时不需要 (通常也不写)函数体,语法是
= 0。
- 声明 :
virtual void foo() = 0;- 实现 :虽然声明时没有函数体,但你依然可以在类外提供实现 。
- 例如:
void Base::foo() { cout << "Base pure virtual implementation"; }- 这种实现通常作为"默认逻辑",供派生类在重写时显式调用(
Base::foo())。析构函数
这是一个非常重要的特例。
- 虚析构函数 :如果你将析构函数声明为虚函数(
virtual ~Base() {}),它必须提供实现 。
- 原因:派生类的析构函数在执行时,会自动调用基类的析构函数。如果基类虚析构函数没有实现,链接器会报错。
- 纯虚析构函数 :即使声明为纯虚(
virtual ~Base() = 0;),它也必须提供实现 !
- 原因同上,因为对象销毁时依然需要调用基类的析构逻辑。
函数类型 声明形式 必须有函数体吗? 备注 普通虚函数 virtual void f();否 如果被调用但没实现,链接报错。 纯虚函数 virtual void f() = 0;否 但可以提供实现供派生类调用。 纯虚析构函数 virtual ~Base() = 0;是 必须提供实现,否则链接报错。
虚函数和纯虚函数的区别?
| 比较维度 | 虚函数 | 纯虚函数 |
|---|---|---|
| 声明方式 | virtual void func(); |
virtual void func() = 0; |
| 函数实现 | 可以有(通常都有),也可以没有(但在调用时必须有)。 | 声明时不需要实现,但允许在类外提供实现。 |
| 所在类实例化 | 包含虚函数的类可以实例化对象。 | 包含纯虚函数的类是抽象类,不能实例化对象。 |
| 派生类要求 | 派生类可选重写。如果不重写,会使用基类的实现。 | 派生类必须重写(除非派生类也想成为抽象类)。 |
| 设计意图 | 提供"默认实现",允许子类根据需要修改行为。 | 定义"接口规范",强制子类必须实现该行为。 |
实例化能力的差异(抽象类 vs 具体类)
- 虚函数:如果一个类只包含普通虚函数,它仍然是一个具体的类。你可以创建这个类的对象。
- 纯虚函数 :只要类中有一个纯虚函数,这个类就变成了抽象类。抽象类就像是一个"半成品"或"图纸",它不能直接生成对象(即不能实例化),只能作为基类被继承。
对派生类的约束力
- 虚函数 :体现的是一种**"默认行为"**。基类在说:"我已经做好了这件事,如果你不介意,就用我的方法;如果你有更好的方法,可以覆盖它。"
- 纯虚函数 :体现的是一种**"强制契约"**。基类在说:"我不知道怎么做这件事,或者这件事必须由具体的子类来决定。任何继承我的子类,都必须告诉我怎么做这件事。"
特殊特例:纯虚析构函数
这是一个容易混淆的点。虽然纯虚函数通常没有实现,但纯虚析构函数必须提供实现。
- 因为派生类析构时,会自动调用基类的析构函数。如果纯虚析构函数没有函数体,链接器就会报错。
cpp
#include <iostream>
using namespace std;
// 基类
class Animal {
public:
// 1. 普通虚函数:有默认实现
virtual void Speak() {
cout << "Animal makes a sound" << endl;
}
// 2. 纯虚函数:强制子类实现
virtual void Eat() = 0;
};
// 派生类
class Dog : public Animal {
public:
// 重写了 Speak,也可以不重写(那样就用 Animal 的)
void Speak() override {
cout << "Dog barks" << endl;
}
// 必须实现 Eat,否则 Dog 也是抽象类,无法实例化
void Eat() override {
cout << "Dog eats meat" << endl;
}
};
int main() {
// Animal a; // 错误:Animal 是抽象类(因为有纯虚函数 Eat),不能实例化
Dog d; // 正确
d.Speak(); // 输出: Dog barks
d.Eat(); // 输出: Dog eats meat
return 0;
}
虚函数的实现机制是什么?
单继承和多继承的虚函数表结构是怎样的?
C++空类的大小是多少?
在 C++ 中,空类的大小通常是 1 字节。
虽然这听起来可能有点反直觉(毕竟类里什么都没有),但这是 C++ 标准为了保证对象在内存中的唯一性而做出的强制规定。
为了让你更全面地理解,我整理了不同情况下的空类大小及其背后的原因:
1. 核心结论:为什么是 1 字节?
C++ 标准规定,每个独立的对象必须拥有唯一的内存地址。
- 如果大小为 0 :假设你定义了一个空类
Empty,并创建了一个数组Empty arr;。如果Empty的大小是 0,那么&arr和&arr的地址就会完全相同(因为地址计算公式是基地址 + 索引 * 大小)。这将导致无法区分不同的对象。- 解决方案 :编译器会隐式地向空类中插入一个占位符(通常是
char类型),使其大小至少为 1 字节。这样,每个对象都能拥有独一无二的地址。2. 不同场景下的大小变化
虽然基础空类是 1 字节,但在加入虚函数或继承时,大小会发生变化。请参考下表:
深入理解:空基类优化
虽然空类本身是 1 字节,但在实际开发(尤其是 STL 源码)中,我们经常利用空基类优化来节省空间。
当你让一个类继承一个空类时,如果派生类本身有数据成员,编译器通常会"偷懒",不给空基类分配那 1 个字节,而是直接利用派生类的内存布局。
cpp#include <iostream> struct Empty {}; struct Derived : public Empty { int x; }; int main() { std::cout << "sizeof(Empty): " << sizeof(Empty) << std::endl; // 输出: 1 std::cout << "sizeof(Derived): " << sizeof(Derived) << std::endl; // 输出: 4 (而不是 1+4=5) return 0; }
- 默认情况 :空类大小为 1 字节,为了区分不同对象的地址。
- 特殊情况:如果有虚函数,大小取决于指针宽度(通常 8 字节);如果是作为基类被继承,通常通过优化不占用额外空间。
只含虚函数的类的大小是多大?
因为含有虚函数的类对象里都会被编译器隐式插入一个虚函数表指针 (教学上常记为 __vptr,但它不是 C++ 标准规定的名字,只是主流编译器的内部实现约定)。它的大小等于平台上的指针大小:32 位机器上是 4 字节,64 位机器上是 8 字节。
cpp
class A { virtual Fun(){} };
int main(){
cout<<sizeof(A)<<endl;// 输出 4(32位机器)/8(64位机器);
A a;
cout<<sizeof(a)<<endl;// 输出 4(32位机器)/8(64位机器);
return 0;
}
一个只包含int 变量的空class和只包含int变量的空struct的内存各占多大?
只含有一个int成员变量的类的大小是4
cppclass A { int a; }; int main(){ cout<<sizeof(A)<<endl;// 输出 4; A a; cout<<sizeof(a)<<endl;// 输出 4; return 0; }只是一个int变量的大小------4字节
只含有一个静态成员变量的类的大小是1
cppclass A { static int a; }; int main(){ cout<<sizeof(A)<<endl;// 输出 1; A a; cout<<sizeof(a)<<endl;// 输出 1; return 0; }静态成员存放在静态存储区,不占用类的大小,普通函数也不占用类大小
cppclass A { static int a; int b; }; int main(){ cout<<sizeof(A)<<endl;// 输出 4; A a; cout<<sizeof(a)<<endl;// 输出 4; return 0; }静态成员a不占用类的大小,所以类的大小就是b变量的大小 即4个字节
