【C++八股】C++三大特性

【C++八股】C++三大特性
本文档参考《代码随想录指示星球精华-最强八股文(第三版)》,结合自己查询资料补充,用于自己补充知识,夯实基础。如有错误,还请各位大佬指正。

一、访问权限

1. 类内部访问权限

在类内部,所有成员(无论是 publicprotectedprivate)均可直接访问:

class Base {
private:
    int c = 30;  // 私有成员
public:
    int a = 10;  // 公有成员
protected:
    int b = 20;  // 受保护成员

    void accessInternal() {
        // 类内部可自由访问所有成员
        std::cout << "a=" << a << ", b=" << b << ", c=" << c << std::endl;
    }
};

输出a=10, b=20, c=30

说明:类内部无访问限制,所有成员均可直接访问。

2. 类外部访问权限

在类外部,只能通过对象访问 public 成员:

Base obj;
std::cout << obj.a;  // 合法:访问公有成员
// std::cout << obj.b;  // 编译错误:无法访问受保护成员
// std::cout << obj.c;  // 编译错误:无法访问私有成员

说明:类外部仅能通过对象访问 public 成员,protectedprivate 成员不可见。

3. 派生类对基类成员的访问

(1) 公有继承(public inheritance)

class Derived : public Base {
public:
    void accessBaseMembers() {
        // 公有继承下,可访问基类的 public 和 protected 成员
        std::cout << "a=" << a << ", b=" << b << std::endl;  // 合法
        // std::cout << c;  // 编译错误:无法访问基类私有成员
    }
};

说明:公有继承时,派生类可访问基类的 publicprotected 成员,但无法访问 private 成员。

(2) 私有继承(private inheritance)

class DerivedPrivate : private Base {
public:
    void accessBaseMembers() {
        // 私有\u001B[1;31mprivate inheritance下,基类的 public/protected 成员在派生类中变为 private
        std::cout << "a=" << a << ", b=" << b << std::endl;  // 合法
    }
};

说明:私有继承后,基类成员在派生类中变为 private,但派生类仍可访问它们。

4. 派生类对象的访问权限

(1) 公有继承对象的访问

Derived d;
std::cout << d.a;  // 合法:基类 public 成员对派生类对象可见
// std::cout << d.b;  // 编译错误:基类 protected 成员对派生类对象不可见

说明:公有继承时,派生类对象仅能访问基类的 public 成员。

(2) 私有/保护继承对象的访问

DerivedPrivate dp;
// std::cout << dp.a;  // 编译错误:私有继承后,基类所有成员对派生类对象不可见

说明:私有或保护继承时,基类的所有成员对派生类对象均不可见。

5. 继承方式对访问权限的总结

继承方式基类 public 成员基类 protected 成员基类 private 成员
publicpublicprotected不可访问
protectedprotectedprotected不可访问
privateprivateprivate不可访问

关键规则

  • 派生类无法访问基类的 private 成员。
  • 继承方式会调整基类成员在派生类中的访问级别。
  • 派生类对象的访问权限取决于继承方式及基类成员的原始访问级别。

二、继承

1. 继承的定义与功能

定义:继承是指让某种类型对象(派生类)获得另一个类型对象(基类)的属性和方法的能力。通过继承,派生类可以复用基类的功能,并在此基础上进行扩展或修改,而无需重新编写基类代码。

功能

  • 代码复用:派生类直接使用基类的属性和方法,减少重复代码。
  • 扩展性:在不修改基类的前提下,通过派生类新增或修改功能。
  • 层次化设计:通过继承关系构建类的层次结构(如从抽象到具体)。

2. C++ 中继承的三种方式

(1) 实现继承

派生类直接复用基类的属性和方法,无需额外编码。

示例

class Animal {  
public:  
    void eat() { cout << "Animal eats." << endl; }  
};  
 
class Dog : public Animal {  // 公有继承  
public:  
    void bark() { cout << "Dog barks." << endl; }  
};  
 
int main() {  
    Dog d;  
    d.eat();    // 直接复用基类方法  
    d.bark();   // 扩展新方法  
}  

特点

  • 派生类完全继承基类的实现,适用于“is-a”关系。

  • 输出:

    Animal eats.
    Dog barks.
    

(2) 接口继承(抽象类实现)

基类定义纯虚函数(接口),派生类必须提供具体实现。

示例

class Shape {  // 抽象类  
public:  
    virtual double area() = 0;  // 纯虚函数  
};  
 
class Circle : public Shape {  
private:  
    double radius;  
public:  
    Circle(double r) : radius(r) {}  
    double area() override { return 3.14 * radius * radius; }  // 实现接口  
};  
 
int main() {  
    Circle c(2.0);  
    cout << "Circle area: " << c.area() << endl;  // 输出 12.56  
}  

特点

  • 基类仅提供接口规范,强制派生类实现。

  • 支持多态,适用于需要统一接口但不同实现的场景。

  • 输出:

    Circle area: 12.56
    

(3) 可视继承(非典型,需结合具体框架)

可视继承通常指子类复用基类的界面外观和实现代码(如 GUI 框架中的窗体继承)。C++ 标准库未直接支持,但可通过组合或框架特性实现。

简化示例(模拟概念)

class BaseWindow {  
public:  
    virtual void draw() { cout << "BaseWindow: Drawing frame." << endl; }  
};  
 
class ChildWindow : public BaseWindow {  
public:  
    void draw() override {  
        BaseWindow::draw();  // 复用基类绘制逻辑  
        cout << "ChildWindow: Adding buttons." << endl;  // 扩展界面元素  
    }  
};  
 
int main() {  
    ChildWindow win;  
    win.draw();   
}  

特点

  • 复用基类的界面布局逻辑,子类扩展个性化内容。

  • 常见于 MFC、Qt 等 GUI 框架。

  • 输出:

    BaseWindow: Drawing frame.
    ChildWindow: Adding buttons.
    

3. 继承的实际应用案例

问题场景:定义“人”的抽象类,具体类继承并扩展行为。

示例

class Person {  
public:  
    virtual void speak() = 0;  // 纯虚函数(接口继承)  
    void sleep() { cout << "Sleeping..." << endl; }  // 实现继承  
};  
 
class Student : public Person {  
public:  
    void speak() override { cout << "Student studies." << endl; }  // 实现接口  
    void takeExam() { cout << "Taking exam." << endl; }  // 扩展功能  
};  
 
int main() {  
    Student s;  
    s.speak();       // 接口继承:输出 "Student studies."  
    s.sleep();       // 实现继承:输出 "Sleeping..."  
    s.takeExam();    // 扩展功能:输出 "Taking exam."  
}  

分析

  • Person 作为抽象类定义通用行为(接口+实现),Student 通过继承复用并扩展功能。

  • 符合面向对象设计的开闭原则(对扩展开放,对修改关闭)。

  • 输出:

    Student studies.
    Sleeping...
    Taking exam.
    

4. 注意事项

  • 访问控制:基类成员在派生类中的可访问性取决于继承方式(publicprotectedprivate)。
  • 虚析构函数:若基类可能被继承,析构函数应声明为 virtual,确保派生类析构时正确调用链式析构。
  • 避免菱形继承:多继承可能导致二义性,可通过虚继承(virtual)解决。

三、 封装

1. 封装的定义

定义:封装是将数据和操作数据的代码(方法)捆绑在类中,通过访问控制机制(如 privatepublic)限制外界对内部实现的直接访问,从而避免外部干扰和不确定性操作。

2. 封装的功能

  • 抽象化:将客观事物(如“人”)抽象为类,隐藏实现细节。
  • 访问控制:仅允许可信的类或对象通过公共接口(public 方法)操作数据,不可信的访问被限制(如 private 成员)。
  • 数据保护:通过封装逻辑(如数据验证),确保数据状态的合法性。

3. C++ 示例

代码

#include <iostream>
#include <string>
using namespace std;
 
class Person { 
private:
    string name;  // 私有数据成员,外部不可直接访问 
    int age;
 
public:
    // 公共构造函数 
    Person(string n, int a) : name(n), age(a) {}
 
    // Getter 方法(公共接口)
    string getName() const { return name; } 
 
    // Setter 方法(带数据验证) 
    void setAge(int a) { 
        if (a > 0 && a < 150)  // 防止非法年龄值 
            age = a;
        else 
            cout << "Invalid age!" << endl; 
    }
 
    // 公共方法 
    void display() const {
        cout << "Name: " << name << ", Age: " << age << endl;
    }
}; 
 
int main() { 
    Person p("Alice", 25);
    p.display();        // 合法调用:通过公共方法访问数据 
 
    // p.age = -5;      // 非法操作:直接访问私有成员会报错 
    p.setAge(-5);       // 通过封装方法验证数据合法性
    p.display();        // 输出仍为 25 
    return 0; 
}

输出

Name: Alice, Age: 25
Invalid age!
Name: Alice, Age: 25

4. 代码解析

  • 封装数据nameage 被声明为 private,外部无法直接修改。
  • 公共接口
    • getName()display() 允许外部读取数据。
    • setAge() 提供安全修改数据的方式,内置验证逻辑。
  • 访问控制效果
    • 若尝试直接访问 p.age,编译器会报错(违反封装规则)。
    • 通过 setAge(-5) 的验证逻辑,非法值被拒绝,数据状态保持合法。

5. 封装的优势

  • 安全性:防止外部直接修改敏感数据(如年龄、账户余额)。
  • 维护性:修改内部实现(如增加数据验证)不影响外部调用代码。
  • 简化使用:用户只需关注公共接口,无需了解内部复杂逻辑。

四、多态

1. 定义与核心概念

定义:多态(Polymorphism)指同一接口在不同对象下表现出不同的行为。C++ 中通过虚函数(运行时多态)和函数重载(编译时多态)实现。

2. 实现方式与示例

2.1 覆盖(Override)——运行时多态

通过虚函数实现,子类重写父类的虚函数,父类指针或引用在运行时动态绑定到子类对象。

示例代码

#include <iostream>
using namespace std;
 
// 基类 
class Animal { 
public:
    virtual void speak() const {  // 虚函数 
        cout << "Animal speaks!" << endl;
    } 
};
 
// 子类1
class Dog : public Animal {
public: 
    void speak() const override {  // 重写虚函数
        cout << "Woof!" << endl; 
    }
};
 
// 子类2 
class Cat : public Animal {
public:
    void speak() const override { 
        cout << "Meow!" << endl;
    } 
};
 
int main() {
    Animal* animal1 = new Dog();
    Animal* animal2 = new Cat(); 
    animal1->speak();  // 输出 "Woof!"
    animal2->speak();  // 输出 "Meow!" 
    delete animal1; 
    delete animal2;
    return 0;
}

输出

Woof!
Meow!

关键点

  • virtual 关键字声明虚函数,启用动态绑定。
  • override 明确标识重写父类方法。
  • 父类指针调用时,实际执行子类的实现。

问题:为什么使用 new 动态分配内存并通过基类指针 (Animal*) 实例化对象,而不是直接实例化对象(如 Dog d; Cat c;)

1. 使用 new 和指针的原因:实现运行时多态

  • 虚函数和动态绑定:代码中的 Animal 基类定义了虚函数 speak()(通过 virtual 关键字),子类 DogCat 重写了该函数。虚函数的目的是通过基类指针或引用调用子类对象的实现,从而实现运行时多态。
  • 基类指针的动态绑定
    • 当使用 Animal* animal1 = new Dog(); 时,animal1 是一个指向 Dog 对象的基类指针。尽管指针类型是 Animal*,但在调用 animal1->speak(); 时,由于 speak() 是虚函数,C++ 会在运行时通过虚函数表(vtable)动态绑定到 Dog 类的 speak() 实现,输出 "Woof!"
    • 类似地,animal2->speak(); 调用 Cat 类的 speak(),输出 "Meow!"
  • 多态的关键:基类指针可以指向任何子类对象,允许在运行时根据实际对象类型调用正确的函数。这种灵活性是面向对象编程中多态的核心特性。

2. 如果直接实例化会怎样?

如果不使用 new 和指针,而是直接实例化对象(如 Dog d; Cat c;),代码如下:

int main() {
    Dog d;
    Cat c;
    d.speak();  // 输出 "Woof!"
    c.speak();  // 输出 "Meow!"
    return 0;
}
  • 结果:代码仍然可以运行,且输出与原代码相同("Woof!""Meow!")。
  • 区别
    • 无多态性:直接实例化的对象类型在编译时是固定的(dDog 类型,cCat 类型)。调用 d.speak()c.speak() 时,编译器直接绑定到对应类的 speak() 函数,而不是通过虚函数表进行运行时动态绑定。
    • 无法统一处理:直接实例化无法将不同子类对象存储在同一个基类类型的容器(如 Animal* 数组或列表)中。例如,无法写 Animal* animals[] = {&d, &c}; 并统一调用 speak(),因为多态需要通过指针或引用实现。
    • 编译时绑定:直接实例化的调用是静态绑定(static binding),不涉及虚函数表的开销,但丧失了运行时多态的灵活性。

3. 为什么不直接用基类引用?

除了指针,C++ 也支持通过引用实现多态。例如:

int main() {
    Dog d;
    Cat c;
    Animal& animal1 = d;  // 基类引用绑定到 Dog 对象
    Animal& animal2 = c;  // 基类引用绑定到 Cat 对象
    animal1.speak();  // 输出 "Woof!"
    animal2.speak();  // 输出 "Meow!"
    return 0;
}
  • 效果:与使用指针类似,引用也能实现运行时多态,调用正确的子类 speak() 函数。
  • 区别
    • 引用不需要手动管理内存(无需 newdelete),更安全。
    • 引用的局限性是必须绑定到已存在的对象,无法像指针那样动态分配或重新指向其他对象。
  • 为什么代码用指针而不用引用:使用 new 和指针可能是为了演示动态分配的场景,或者为了更灵活地管理对象生命周期(例如,将指针存储在容器中或动态切换指向的对象)。此外,指针在多态场景中更常见,尤其是在需要动态分配内存或对象生存期不确定的情况下。

4. 使用 new 和指针的注意事项

  • 内存管理:代码中通过 new 分配内存,必须用 delete 释放,否则会导致内存泄漏。示例中正确使用了 delete animal1; delete animal2;
  • 虚析构函数:如果基类有虚函数(如 speak()),通常需要将析构函数声明为虚函数(virtual ~Animal() {}),以确保通过基类指针删除子类对象时,子类的析构函数被正确调用。示例代码中缺少虚析构函数,可能导致未定义行为(如果子类有需要清理的资源)。
  • 性能开销:使用虚函数和指针涉及虚函数表查找,相比直接实例化的静态绑定有轻微性能开销,但通常可以忽略。

5. 为什么示例选择指针而非直接实例化?

示例代码的目的是展示运行时多态的经典用法:

  • 使用基类指针 (Animal*) 指向不同子类对象 (DogCat),通过同一接口 (speak()) 调用不同实现,体现了多态的灵活性。
  • 指针允许模拟真实场景,如将不同类型的对象统一存储或处理(例如,vector<Animal*> animals;)。
  • 动态分配(new)强调对象在运行时的动态创建,适合展示多态在复杂系统中的应用。
  • 如果示例仅需展示子类行为而无需多态,直接实例化会更简单,但无法体现面向对象编程中多态的核心价值。

2.2 重载(Overload)——编译时多态

同一作用域内,函数名相同但参数列表不同(类型、数量、顺序)。

示例代码

#include <iostream>
using namespace std;
 
// 基础函数 
int add(int a, int b) {
    return a + b;
} 
 
// 重载:参数类型不同 
double add(double a, double b) {
    return a + b; 
}
 
// 重载:参数数量不同
int add(int a, int b, int c) {
    return a + b + c;
}
 
int main() { 
    cout << add(2, 3) << endl;      // 调用 int add(int, int) 
    cout << add(2.5, 3.1) << endl;  // 调用 double add(double, double)
    cout << add(1, 2, 3) << endl;   // 调用 int add(int, int, int) 
    return 0; 
}

输出

5
5.6
6

关键点

  • 重载仅依赖参数列表,与返回值无关。
  • 编译时根据参数类型和数量确定调用哪个函数。

3. 多态的核心区别

特性覆盖(Override)重载(Overload)
绑定时机运行时(动态绑定)编译时(静态绑定)
作用范围继承关系中的虚函数同一作用域内的函数
关键字virtual + override无特殊关键字
示例场景父类指针调用子类方法同一函数名处理不同输入类型

4. 注意事项

  • 虚析构函数:若基类可能被继承,析构函数需声明为虚函数,防止内存泄漏。
  • 纯虚函数与抽象类:包含纯虚函数(virtual void func() = 0;)的类不可实例化,用于定义接口。
  • 性能差异:虚函数调用需通过虚函数表,略微影响性能;重载无额外开销。

五、虚函数

1. 虚函数的基本用法

虚函数通过**虚函数表(vtable)和虚函数指针(vptr)**实现多态。当基类希望派生类定义自己的版本时,需将函数声明为 virtual

示例代码

#include <iostream>
using namespace std;
 
// 基类 
class Animal { 
public:
    virtual void speak() { cout << "Animal speaks!" << endl; }
};
 
// 派生类
class Dog : public Animal { 
public:
    void speak() override { cout << "Dog barks!" << endl; }
}; 
 
int main() { 
    Animal* animal = new Dog(); // 基类指针指向派生类对象 
    animal->speak();            // 输出 "Dog barks!" 
    delete animal;
}

关键点

  • animal->speak() 调用的是 Dogspeak(),而非 Animal 的版本。
  • 虚函数表在运行时根据对象的实际类型动态绑定函数。

2. 多态的必要条件

多态需要满足以下条件:

  • 调用者必须是基类指针或引用。
  • 被调用的函数必须是虚函数。
  • 派生类必须重写基类的虚函数。

示例代码

class Base { 
public:
    virtual void foo() { cout << "Base::foo" << endl; }
}; 
 
class Derived : public Base {
public: 
    void foo() override { cout << "Derived::foo" << endl; }
};
 
void callFoo(Base& obj) {
    obj.foo();  // 动态绑定 
}
 
int main() {
    Derived d;
    callFoo(d); // 输出 "Derived::foo"
}

3. 虚函数与构造函数

构造函数不能是虚函数,且在构造函数中调用虚函数会禁用多态。

示例代码

class Base { 
public:
    Base() { init(); } // 构造函数调用虚函数 
    virtual void init() { cout << "Base::init" << endl; } 
};
 
class Derived : public Base {
public:
    void init() override { cout << "Derived::init" << endl; }
};
 
int main() { 
    Derived d; // 输出 "Base::init",而非 "Derived::init"
}

原因

  • 对象构造时,虚函数表指针(vptr)尚未指向派生类的虚函数表。

4. 虚析构函数的必要性

如果基类可能被继承,析构函数应声明为虚函数,以确保派生类析构函数被正确调用。

示例代码

class Base {
public: 
    virtual ~Base() {} // 虚析构函数
};
 
class Derived : public Base {
public: 
    ~Derived() { cout << "Derived destroyed" << endl; }
};
 
int main() { 
    Base* obj = new Derived(); 
    delete obj; // 会调用 Derived::~Derived() 
}

未声明虚析构函数的后果

  • 若基类析构函数非虚,delete 基类指针时只会调用基类析构函数,导致派生类资源未释放。

5. 纯虚函数与抽象类

纯虚函数(= 0)使类成为抽象类,不能直接实例化。

示例代码

class Shape {
public: 
    virtual void draw() = 0; // 纯虚函数
}; 
 
class Circle : public Shape {
public: 
    void draw() override { cout << "Drawing Circle" << endl; } 
};
 
int main() {
    // Shape s; // 错误:不能实例化抽象类 
    Shape* shape = new Circle();
    shape->draw(); // 输出 "Drawing Circle" 
}

纯虚析构函数的特殊规则

  • 纯虚析构函数必须提供定义体,因为派生类析构时会隐式调用基类析构函数。

示例代码

class Base {
public:
    virtual ~Base() = 0; // 纯虚析构函数
}; 
Base::~Base() {} // 必须定义体 

6. 虚函数的限制

以下函数不能为虚函数:

  • 内联函数(inline):内联在编译时展开,与虚函数的运行时绑定冲突。
  • 静态函数(static):无 this 指针,无法访问虚函数表。
  • 构造函数:对象构造前虚函数表未初始化。

示例代码

class MyClass {
public: 
    // 错误示例:虚函数不能是 static 或 inline 
    virtual static void func1() {} // 编译错误 
    virtual inline void func2() {} // 通常不推荐
}; 

7. 返回值协变(Return Type Covariance)

派生类虚函数可以返回基类虚函数返回类型的派生类型。
Return Type Covariance 是C++中虚函数重写时的一个特殊规则,允许派生类虚函数的返回类型为基类虚函数返回类型的派生类型。

1. 核心规则

  • 适用条件:基类虚函数的返回类型必须是指针(*)或引用(&),派生类才能返回其派生类型。例如:
class Base { virtual Base* func(); };
class Derived : public Base { 
    Derived* func() override { return new Derived(); } // 合法:返回类型是 Base* 的派生类型 
};
  • 若基类返回类型是普通对象(如 Base func()),则派生类无法协变。

  • 继承关系要求:派生类返回类型必须与基类返回类型存在继承关系。例如,若基类返回 Animal*,派生类可返回 Dog*(需 Dog 继承自 Animal)。

2. 示例详解

用户提供的代码已满足协变条件,以下是补充细节的示例:

#include <iostream>
using namespace std;

class Animal { 
public: 
    virtual void speak() {} // 需有虚函数,否则无法多态 
};
class Dog : public Animal { 
public: 
    void speak() override { cout << "Woof!" << endl; } 
};
 
class Farm {
public: 
    virtual Animal* getAnimal() { return new Animal(); }
};
 
class DogFarm : public Farm {
public: 
    Dog* getAnimal() override { return new Dog(); } // 协变:返回 Dog*(Animal* 的派生类型)
}; 
 
int main() { 
    Farm* farm = new DogFarm();
    Animal* animal = farm->getAnimal(); // 多态调用:实际返回 Dog*
    animal->speak(); // 输出 "Woof!"
    delete farm;
    delete animal;
}

关键点

  • DogFarm::getAnimal() 返回 Dog*,但通过基类指针 farm 调用时,仍能正确绑定到 Dog 的实现。
  • 若基类 FarmgetAnimal() 返回 Animal(非指针),则派生类无法协变。

3. 作用与优势

  • 避免强制类型转换:无需对返回值进行 dynamic_cast 转换即可直接使用派生类型。
  • 接口灵活性:允许基类定义通用接口,派生类提供更具体的返回类型,增强代码可扩展性。
  • 多态一致性:保持虚函数动态绑定特性的同时,支持更精确的类型表达。

4. 常见错误与限制

  • 非指针/引用返回类型
class Base { virtual Base func(); };
class Derived : public Base { 
    Derived func() override { return Derived(); } // 错误:返回类型非指针/引用,无法协变 
};
  • 无关类型
class Base { virtual Base* func(); }; 
class Derived : public Base {
    int* func() override { return new int(5); } // 错误:int* 与 Base* 无继承关系 
};

5. 实际应用场景

  • 工厂模式:基类定义通用工厂接口,派生类生成具体产品类型。
class Product { 
public: 
    virtual void use() = 0; 
}; 
class ConcreteProduct : public Product { 
public: 
    void use() override { /* 实现 */ } 
}; 

class Creator { 
public:
    virtual Product* create() = 0; 
}; 

class ConcreteCreator : public Creator { 
public:
    ConcreteProduct* create() override { return new ConcreteProduct(); } 
};
  • 虚函数表机制:协变依赖虚函数表的动态绑定,派生类虚函数表会替换对应条目指向自身实现。
  • 析构函数:若基类析构函数为虚函数,派生类析构时可正确调用链式析构,避免内存泄漏。

8. 虚函数表(vtable)的底层机制

  • 虚函数表:每个类维护一个虚函数表,存储虚函数的地址。
  • 虚函数指针(vptr):每个对象隐藏一个指针,指向其所属类的虚函数表。

示例代码

class Base {
public:
    virtual void foo() { cout << "Base::foo" << endl; }
};
 
class Derived : public Base {
public: 
    void foo() override { cout << "Derived::foo" << endl; } 
};
 
int main() {
    Base b;
    Derived d;
 
    // 伪代码:假设 vptr 指向虚函数表
    void** vptrBase = *reinterpret_cast<void***>(&b);
    void** vptrDerived = *reinterpret_cast<void***>(&d); 
 
    // 调用虚函数 
    ((void(*)())vptrBase[0])();   // 调用 Base::foo 
    ((void(*)())vptrDerived[0])(); // 调用 Derived::foo 
}

内存布局

  • Base 对象:vptrBase 虚函数表(含 Base::foo 地址)。
  • Derived 对象:vptrDerived 虚函数表(含 Derived::foo 地址)。

为什么需要虚继承

在 C++ 中,虚继承主要用于解决多继承场景下的菱形继承问题,即多个基类共享同一个间接基类时导致的数据冗余和访问歧义问题。以下是具体分析及示例:

1. 核心问题:菱形继承导致的冗余与歧义

当类 D 同时继承自类 B 和类 C,而类 B 和类 C 又共同继承自类 A 时,若未使用虚继承,类 D 会包含两份类 A 的成员(如成员变量 a)。此时访问 a 时,编译器无法确定是来自 A→B→D 路径还是 A→C→D 路径,导致歧义。例如:

#include <iostream>
using namespace std;

class A { public: int a; }; 
class B : public A {};  // 非虚继承
class C : public A {};  // 非虚继承 
class D : public B, public C {
    void func() { 
        cout << a;  // 错误!a 来自 B::A 还是 C::A?歧义! 
    }
};

2. 虚继承的解决方案

通过将基类 A 声明为虚基类,确保在派生类 D 中仅保留一份 A 的成员,消除冗余和歧义:

#include <iostream>
using namespace std;

class A { public: int a; }; 
class B : virtual public A {};  // 虚继承
class C : virtual public A {};  // 虚继承 
class D : public B, public C {
    void func() { 
        cout << a;  // 正确!a 唯一来自虚基类 A
    } 
};

说明:此时,D 对象的内存布局中,BC 共享同一个 A 实例,避免了重复存储。

3. 虚继承的实现机制

  • 虚基类指针(vbptr):编译器会为每个虚继承的类添加一个指针(vbptr),指向虚基类表,记录虚基类成员的偏移量,确保访问时能正确定位共享的基类成员。
  • 内存布局变化
    • 非虚继承时,D 对象包含两个 A 子对象(分别来自 BC)。
    • 虚继承后,D 对象仅包含一个 A 实例,BC 通过 vbptr 间接访问该实例。

4. 实际应用案例

C++ 标准库中的 iostream 类是虚继承的经典应用:

class baseios;          // 共同基类
class istream : virtual public baseios {};  // 虚继承
class ostream : virtual public baseios {};  // 虚继承
class iostream : public istream, public ostream {};  // 仅保留一份 baseios 成员

说明:若不使用虚继承,iostream 将包含两份 baseios 成员,导致冗余和操作混乱。

5. 虚继承的代价与适用场景

优点

  • 消除菱形继承中的冗余数据。
  • 解决跨路径访问基类成员的歧义问题。

缺点

  • 增加内存开销(需存储 vbptr)。
  • 降低访问效率(需通过 vbptr 动态定位基类成员)。

适用场景

  • 需要共享基类状态的多继承结构(如接口类、混合类设计)。
  • 标准库或框架中需避免重复基类实例的场景。

六、空类

空类的大小不是 0,而是 1 字节,这是为了确保每个对象在内存中都有唯一的地址。如果空类的大小为 0,那么多个空类对象可能会共享同一个地址,这违反了 C++ 的对象模型要求。因此,编译器会为每个空类隐式添加一个字节,以确保对象地址的唯一性。

一、空类大小为 1 的原因

示例代码

class A {};

sizeof(A) 的结果是 1

原因

  • 确保每个对象在内存中都有唯一的地址。
  • 编译器自动添加一个字节(通常称为“填充字节”)来实现这一点。

二、虚函数与虚继承对类大小的影响

1. 含有虚函数的类

示例代码

class A {
    virtual void f() {} 
};

sizeof(A) 的结果是 4(32 位系统)或 8(64 位系统)。

原因

  • 类中包含一个指向虚函数表的指针(vptr),用于支持多态行为。
  • 在 32 位系统中,指针占 4 字节;在 64 位系统中占 8 字节。

2. 虚继承

示例代码

class A {};
class B : public virtual A {};

sizeof(B) 的结果是 4

原因

  • 虚继承引入了一个虚基类指针(vbptr),用于支持虚基类的访问。
  • vbptr 指向虚 base class table,记录虚基类相对于派生类的偏移量。
  • 在 32 位系统中,指针占 4 字节。

三、多重继承与虚继承的大小分析

示例 1:多重继承(非虚继承)

示例代码

class Father1 {};
class Father2 {};
class Child : public Father1, public Father2 {};

sizeof(Child) 的结果是 1

原因

  • 两个基类都是空类,编译器可以进行空基类优化(Empty Base Class Optimization)
  • 所有空基类共享同一个内存地址,因此派生类大小仍为 1。

示例 2:虚继承链

示例代码

class X {};
class Y : public virtual X {}; 
class Z : public virtual X {};
  • sizeof(X) = 1
  • sizeof(Y) = 4
  • sizeof(Z) = 4

原因

  • YZ 都虚继承 X,引入 vbptr,每个 vbptr 占 4 字节。

示例 3:虚继承链的扩展

示例代码

class A : public virtual Y {}; 

sizeof(A) = 8

原因

  • A 虚继承 Y,引入一个 vbptr。
  • Y 本身已经有一个 vbptr(指向 X)。
  • A 的布局中包含 Y 的子对象(4 字节)和 A 的 vbptr(4 字节),总大小为 8。

示例 4:多重虚继承

示例代码

class B : public Y, public Z {};

sizeof(B) = 8

原因

  • B 非虚继承 YZYZ 各自带一个 vbptr(各 4 字节)。
  • B 的布局中包含 YZ 的子对象,总大小为 8。

示例 5:虚继承多个虚基类

示例代码

class C : public virtual Y, public virtual Z {};

sizeof(C) = 12

原因

  • C 虚继承 YZ,引入两个 vbptr(各 4 字节)。
  • 每个 vbptr 指向各自的虚基类表。
  • 总大小为 12(4 * 3)。

示例 6:虚继承虚继承链

示例代码

class D : public virtual C {};

sizeof(D) = 16

原因

  • D 虚继承 C,引入一个 vbptr(4 字节)。
  • C 的大小为 12,D 的布局中包含 C 的子对象(12 字节)和自己的 vbptr(4 字节)。
  • 总大小为 16。

四、虚函数表共享规则

1. 虚函数表共享的条件

  • 如果派生类的第一个基类定义了虚函数表,那么派生类会共享该虚函数表的地址。
  • 否则,派生类会创建自己的虚函数表。

2. 示例分析

示例代码

class A { virtual void f() {} };
class B : public A {};  // 共享 A 的虚函数表,大小为 4 
class C : public A, public X { virtual void g() {} };  // 自己创建虚函数表,大小为 8
  • sizeof(B) = 4(共享 A 的虚函数表)
  • sizeof(C) = 8A 有虚函数表,X 是空类,C 自己创建虚函数表)

五、总结

类型大小(32 位系统)原因
空类1确保对象地址唯一
含虚函数4引入虚函数表指针(vptr)
虚继承4引入虚基类指针(vbptr)
多重非虚继承1空基类优化
虚继承链8基类子对象 + vbptr
多重虚继承12多个 vbptr
虚继承虚继承链16子对象 + vbptr

空类的作用是什么

空类的用途主要体现在以下几个方面:

1. 确保对象实例的唯一地址

空类的大小为 1 字节(而非 0),这是为了保证每个实例在内存中有唯一的地址。若空类大小为 0,多个实例可能共享同一地址,导致指针比较或容器存储时出现歧义。

示例代码

#include <cassert>

class Empty {};
Empty e1, e2;
assert(&e1 != &e2); // 确保地址不同 

2. 作为虚基类解决多继承问题

在虚继承中,空类可作为共享的虚基类,避免菱形继承导致的冗余数据。

示例代码

class Base {}; // 空类作为虚基类
class Derived1 : virtual public Base {};
class Derived2 : virtual public Base {};
class Final : public Derived1, public Derived2 {}; // 只保留一份 Base 成员

说明:此时 Final 类的大小会包含指向虚基类的指针(通常为 4 字节)。

3. 接口或标记类的设计

空类可用于定义纯接口(无方法或数据成员),或作为类型标记。例如,通过私有化拷贝构造函数和赋值运算符,禁用对象拷贝。

示例代码

class NonCopyable {
private:
    NonCopyable(const NonCopyable&) = delete;
    NonCopyable& operator=(const NonCopyable&) = delete; 
};
// 子类继承 NonCopyable 后无法拷贝 

4. 模板元编程中的类型特征

空类可作为模板参数传递类型信息,例如标准库中的 std::true_typestd::false_type,用于启用或禁用特定模板特化。

5. 内存对齐与占位

在需要精确控制内存布局的场景(如硬件寄存器映射),空类可能用于占位或对齐,确保后续成员按预期排列。

七、抽象类与接口实现

抽象类(Abstract Class)是实现接口(Interface)的主要手段。通过将类中的某些成员函数声明为纯虚函数(Pure Virtual Function),我们可以定义一个接口,要求派生类必须实现这些函数。这种机制不仅提供了多态性,还实现了面向对象设计中的接口与实现分离的原则。

1.抽象类与纯虚函数的定义

在 C++ 中,抽象类是指至少包含一个纯虚函数的类。纯虚函数的声明形式如下:

virtual 返回类型 函数名(参数列表) = 0;

示例代码

class Shape {
public:
    virtual int getArea() = 0; // 纯虚函数
};
  • 抽象类不能被实例化,即不能创建该类的对象。
  • 派生类必须实现所有纯虚函数,否则它本身也将成为抽象类。
  • 抽象类通常作为基类,为派生类提供接口框架。

2.接口的实现方式

在 C++ 中,接口并没有像 Java 或 C# 那样的专用关键字(如 interface),而是通过只包含纯虚函数的抽象类来模拟接口。

示例代码

class ShapeInterface {
public: 
    virtual int getArea() = 0;
    virtual void draw() = 0;
    virtual ~ShapeInterface() {} // 推荐添加虚析构函数
}; 

这样的类仅定义行为(方法),不包含任何实现或数据成员,符合接口的定义。

3.示例代码解析

以下是一个典型的抽象类与接口实现的例子:

1. 抽象类 Shape

class Shape {
public: 
    virtual int getArea() = 0; // 纯虚函数,定义接口 
    void setWidth(int w) { width = w; }
    void setHeight(int h) { height = h; }
protected: 
    int width; 
    int height; 
};
  • getArea() 是纯虚函数,表示这是一个接口方法。
  • setWidth()setHeight() 是普通虚函数,提供默认实现。
  • widthheight 是受保护的成员变量,供派生类访问。

2. 派生类 Rectangle 和 Triangle

class Rectangle : public Shape {
public: 
    int getArea() override { 
        return width * height;
    }
}; 
 
class Triangle : public Shape {
public: 
    int getArea() override { 
        return (width * height) / 2;
    } 
};
  • 这两个类都实现了 getArea() 方法,满足了接口的要求。
  • 它们继承了 Shapewidthheight 成员,并通过 setWidth()setHeight() 设置值。

3. 主函数调用

#include <iostream>

int main() {
    Rectangle Rect;
    Triangle Tri;
 
    Rect.setWidth(5);  
    Rect.setHeight(7); 
    std::cout << "Rectangle Area: " << Rect.getArea() << std::endl; // 输出 35
 
    Tri.setWidth(5);  
    Tri.setHeight(7); 
    std::cout << "Triangle Area: " << Tri.getArea() << std::endl; // 输出 17
} 
  • 通过基类指针或引用调用派生类的方法,体现了运行时多态(Runtime Polymorphism)
  • getArea() 的具体实现由对象类型决定,而非指针类型。

4.抽象类的设计目的

  • 提供接口框架:抽象类定义了所有派生类必须遵循的行为规范。
  • 实现多态性:通过虚函数和继承,允许在运行时根据对象的实际类型调用不同的函数。
  • 封装与解耦:接口与实现分离,提高了代码的可维护性和可扩展性。
  • 强制派生类实现特定方法:纯虚函数确保派生类必须实现某些关键功能。

5.注意事项与最佳实践

  • 虚析构函数:如果抽象类可能被继承并用于多态删除(如通过基类指针删除派生类对象),则必须为析构函数添加 virtual,以避免未定义行为。
virtual ~Shape() {}
  • 抽象类可以有数据成员和实现:与接口不同,抽象类可以包含数据成员和非虚函数的实现,这使得它更灵活,但同时也增加了耦合性。
  • 接口与抽象类的区别
    • 接口:只包含纯虚函数,无数据成员,用于定义行为规范。
    • 抽象类:可以包含数据成员和实现,用于提供部分实现,作为基类使用。
### C++ 常见面试题及答案 #### 虚析构函数的重要性 基类的析构函数应该声明为虚函数的原因在于确保派生类的析构函数能够被正确调用。当通过基类指针删除一个派生类对象时,如果基类的析构函数不是虚函数,则只会调用基类的析构函数,而不会调用派生类的析构函数,这可能导致资源泄漏或其他未定义行为[^1]。 #### 引用的特点 引用有以下几个特性: - 不占用额外的内存空间,它与所引用的对象共享同一存储位置; - 必须在创建时初始化,并且一旦绑定到某个对象之后就不能再改变指向; - 类型需匹配,即引用与其目标实体的数据类型应当相同; - 即使原始变量发生变化,其对应的引用也会反映这些变化;反之亦然。 例如,在C++中,`int& ref = value;` 表示 `ref` 是对整数类型的引用[^4]。 #### 自增运算符的区别 前置自增(`++i`) 和 后置自增 (`i++`) 的主要区别在于它们的行为不同。对于前置版本而言,操作会立即生效并返回更新后的值作为左值表达式的一部分;而对于后置版本来说,先复制当前状态下的数值给临时量后再执行加法赋值动作,因此效率上不如前者高,因为涉及到更多内部处理过程如构造和销毁临时对象等[^3]。 ```cpp // 示例代码展示两种增量方式的不同之处 void incrementExample() { int i = 0; // 使用前置++ ++i; // 返回的是修改后的i // 使用后置++ (void)(i++); // 需要创建副本, 修改原值但返回旧值 } ``` #### 关于C++职业发展 C++作为一种广泛应用于系统软件开发、嵌入式设备编程以及高性能计算领域的高级语言,掌握好它可以为求职者打开通往多个行业的就业门。由于该语言本身具备强的灵活性和控制力,所以从事这类工作的人才往往能获得不错的薪资待遇和发展前景[^2]。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值