万字长文详解 C++ 面向对象编程:从基础到高级的深度探索

在 C++ 的世界里,面向对象编程(Object - Oriented Programming,OOP)是其核心编程范式之一,它就像一座大厦的基石,支撑起无数复杂而强大的软件系统。C++ 面向对象编程涵盖了丰富的概念和特性,从类与对象的基础构建,到多态、继承等高级特性,每一个部分都充满了智慧与技巧。接下来,让我们深入探索 C++ 面向对象编程的各个方面。

一、类与对象:编程世界的蓝图与实体

(一)类的概念

类是 C++ 面向对象编程的基础,它可以看作是一种自定义的数据类型,是对具有相同属性和行为的事物的抽象描述。打个比方,类就像是汽车制造的蓝图,它定义了汽车的各种属性,如颜色、品牌、型号,以及汽车的行为,如启动、加速、刹车。在 C++ 中,类的定义包含了数据成员(属性)和成员函数(行为)。

class Car {
private:
    std::string color;
    std::string brand;
    std::string model;
public:
    // 成员函数
    void start() {
        std::cout << "The car starts." << std::endl;
    }
    void accelerate() {
        std::cout << "The car accelerates." << std::endl;
    }
    void brake() {
        std::cout << "The car brakes." << std::endl;
    }
};

在这个Car类的定义中,colorbrandmodel是数据成员,用于描述汽车的属性;而startacceleratebrake是成员函数,用于定义汽车的行为。

(二)对象的概念

对象是类的实例化,是根据类的定义创建出来的具体实体。如果类是蓝图,那么对象就是按照蓝图制造出来的一辆辆真实的汽车。每个对象都有自己独立的存储空间,存储着各自的数据成员的值,并且可以调用类中定义的成员函数。

int main() {
    Car myCar;
    myCar.start();
    return 0;
}

在上述代码中,myCar就是Car类的一个对象,通过这个对象可以调用start函数,执行汽车启动的行为。

(三)属性和行为

属性即类中的数据成员,用于描述对象的状态;行为即类中的成员函数,用于定义对象可以执行的操作。属性和行为紧密结合,共同构成了类的完整定义。属性为行为提供操作的数据,而行为则对属性进行操作和处理 。例如,在Car类中,color属性可以通过成员函数进行修改,而accelerate行为会根据汽车的当前状态(如速度等属性)进行相应的操作。

二、对象权限与属性私有化

(一)对象权限

在 C++ 中,类的成员可以有不同的访问权限,主要包括public(公共)、private(私有)和protected(保护)。public成员在类的外部可以直接访问;private成员只能在类的内部被访问,类的外部无法直接访问;protected成员与private成员类似,但在继承关系中有特殊的访问规则。

(二)属性私有化

将类的属性设置为private是一种常见的做法,这样可以保证数据的安全性和封装性。外部代码不能直接修改私有属性的值,只能通过类中提供的公共成员函数(如设置函数setter和获取函数getter)来访问和修改属性。

class Car {
private:
    std::string color;
public:
    void setColor(const std::string& c) {
        color = c;
    }
    std::string getColor() const {
        return color;
    }
};

通过将color属性私有化,并提供setColorgetColor函数,我们可以在函数内部添加一些验证逻辑,确保属性值的合法性。例如,在setColor函数中可以检查颜色字符串是否符合规定的格式。

(c++类内不声明权限时默认是私有)

三、构造函数与析构函数

(一)构造函数

构造函数是一种特殊的成员函数,它的主要作用是在对象创建时初始化对象的数据成员。构造函数的名称与类名相同,并且没有返回值(也不能写void)。

class Car {
private:
    std::string color;
    std::string brand;
public:
    // 构造函数
    Car(const std::string& c, const std::string& b) : color(c), brand(b) {
        std::cout << "Car object is constructed." << std::endl;
    }
};

在上述代码中,Car类的构造函数接受两个参数,用于初始化colorbrand属性。当创建Car对象时,构造函数会自动被调用。

(二)析构函数

析构函数也是一种特殊的成员函数,它在对象生命周期结束时被自动调用,用于释放对象在生命周期内分配的资源,如动态分配的内存等。析构函数的名称是在类名前加上波浪线~,同样没有返回值。

class Car {
private:
    std::string* color;
public:
    Car(const std::string& c) {
        color = new std::string(c);
    }
    ~Car() {
        delete color;
        std::cout << "Car object is destructed." << std::endl;
    }
};

在这个例子中,Car类的构造函数动态分配了内存来存储color字符串,析构函数则负责释放这块内存,防止内存泄漏。

(三)构造析构顺序

当涉及到继承、组合等复杂情况时,对象的构造和析构顺序有严格的规定。在继承关系中,先调用基类的构造函数,再调用派生类的构造函数;析构顺序则相反,先调用派生类的析构函数,再调用基类的析构函数。在组合关系中,先调用成员对象的构造函数,再调用包含类的构造函数;析构顺序同样相反 。

四、拷贝构造函数与初始化列表

(一)拷贝构造函数

拷贝构造函数是一种特殊的构造函数,用于用一个已有的对象来初始化另一个新对象。它的参数是本类对象的引用,通常用于对象的复制操作。

class Car {
private:
    std::string color;
public:
    Car(const std::string& c) : color(c) {}
    // 拷贝构造函数
    Car(const Car& other) : color(other.color) {
        std::cout << "Copy constructor is called." << std::endl;
    }
};

在上述代码中,Car类的拷贝构造函数将源对象的color属性值复制到新对象中。当进行对象赋值、函数传参(按值传递)、函数返回对象等操作时,拷贝构造函数可能会被调用。

(二)初始化列表

初始化列表是在构造函数定义中,用于初始化数据成员的一种方式。它的语法是在构造函数的参数列表后面加上冒号,然后列出需要初始化的数据成员及其初始值。使用初始化列表可以提高代码的效率,尤其是对于一些需要进行初始化的常量成员、引用成员以及基类成员等。

class Car {
private:
    const int wheelCount;
    std::string& name;
public:
    Car(std::string& n) : wheelCount(4), name(n) {}
};

在这个例子中,wheelCount是常量成员,name是引用成员,必须使用初始化列表进行初始化。

五、静态成员变量与静态成员函数

(一)静态成员变量

静态成员变量是属于类的变量,而不是属于某个具体的对象。无论创建多少个类的对象,静态成员变量都只有一份,被所有对象共享。静态成员变量需要在类外进行定义和初始化。

class Car {
private:
    static int totalCars;
public:
    Car() {
        totalCars++;
    }
    static int getTotalCars() {
        return totalCars;
    }
};
int Car::totalCars = 0;

在上述代码中,totalCarsCar类的静态成员变量,用于统计创建的Car对象的总数。通过getTotalCars静态成员函数可以获取这个总数。

(二)静态成员函数

静态成员函数也是属于类的函数,它不依赖于任何具体的对象,可以直接通过类名调用。静态成员函数只能访问静态成员变量和其他静态成员函数,不能访问非静态成员变量和非静态成员函数,因为非静态成员属于具体的对象,而静态成员函数没有this指针(后面会详细介绍this指针)。

class Car {
private:
    static int totalCars;
public:
    static void printTotalCars() {
        std::cout << "Total cars: " << totalCars << std::endl;
    }
};

在这个例子中,printTotalCars是静态成员函数,用于打印totalCars的值。

六、this 指针

this指针是 C++ 中一个重要的概念,它指向当前正在操作的对象。在类的成员函数中,this指针被隐式传递,可以使用this指针来访问和操作对象的成员变量和成员函数。

class Car {
private:
    std::string color;
public:
    Car(const std::string& c) {
        this->color = c;
    }
    std::string getColor() const {
        return this->color;
    }
};

在上述代码中,this指针用于明确区分构造函数的参数c和类的成员变量color。当对象调用成员函数时,this指针会被自动传递给成员函数,指向调用该函数的对象。

七、const 修饰成员变量与 mutable

(一)const 修饰成员变量

const修饰的成员变量是常量成员变量,一旦初始化后就不能再被修改。常量成员变量必须在构造函数中进行初始化。

class Car {
private:
    const int maxSpeed;
public:
    Car(int speed) : maxSpeed(speed) {}
};

在这个例子中,maxSpeed是常量成员变量,表示汽车的最大速度,在对象创建时通过初始化列表进行赋值,之后不能再被修改。

(二)mutable

mutable关键字用于修饰成员变量,即使在const成员函数中,也可以修改被mutable修饰的成员变量。这是因为const成员函数承诺不会修改对象的状态,但有些情况下,我们可能希望在const成员函数中对某些成员变量进行修改,这时就可以使用mutable关键字。

class Car {
private:
    mutable int accessCount;
public:
    void use() const {
        accessCount++;
    }
};

在上述代码中,accessCount用于记录Car对象被使用的次数,即使在const成员函数use中,也可以对其进行修改。

八、继承语法和方式

(一)继承语法

继承是 C++ 面向对象编程的重要特性之一,它允许一个类(派生类)继承另一个类(基类)的属性和行为。继承的语法是在派生类的定义中使用冒号,后面跟着继承的方式和基类名称。

class Vehicle {
public:
    void move() {
        std::cout << "The vehicle moves." << std::endl;
    }
};
class Car : public Vehicle {
private:
    std::string color;
public:
    Car(const std::string& c) : color(c) {}
};

在这个例子中,Car类继承自Vehicle类,Car类可以使用Vehicle类中定义的move函数。

(二)继承方式  

C++ 中有三种继承方式:public继承、private继承和protected继承。

  • public继承时,基类的public成员在派生类中仍然是public成员,protected成员在派生类中仍然是protected成员;

  • private继承时,基类的publicprotected成员在派生类中都变为private成员;

  • protected继承时,基类的publicprotected成员在派生类中都变为protected成员。不同的继承方式会影响成员在派生类及其子类中的访问权限。

九、同名属性与函数访问

(一)同名属性访问

当派生类和基类存在同名属性时,在派生类中访问该属性,如果没有特殊指定,默认访问的是派生类自己的属性。如果想要访问基类的同名属性,可以使用作用域运算符::

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

在上述代码中,Derived类和Base类都有一个名为num的属性,在Derived类的print函数中,通过不同的方式分别访问了派生类和基类的num属性。

(二)同名函数访问

当派生类和基类存在同名函数时,如果函数的参数列表相同,派生类的函数会覆盖基类的函数,这种情况称为函数重写(覆盖)。如果函数的参数列表不同,则称为函数重载。在调用同名函数时,会根据对象的类型和函数的参数列表来确定调用哪个函数。

class Base {
public:
    void func() {
        std::cout << "Base func" << std::endl;
    }
};
class Derived : public Base {
public:
    void func() {
        std::cout << "Derived func" << std::endl;
    }
};

在这个例子中,Derived类重写了Base类的func函数,当通过Derived类对象调用func函数时,会调用派生类的func函数。

十、多态、虚函数与纯虚函数

(一)多态

多态是 C++ 面向对象编程的核心特性之一,它允许用基类的指针或引用指向派生类的对象,并根据对象的实际类型调用相应的函数。多态分为静态多态(编译时多态,通过函数重载实现)和动态多态(运行时多态,通过虚函数实现)。

(二)虚函数

虚函数是实现动态多态的关键。在基类中使用virtual关键字声明的函数就是虚函数,派生类可以重写(覆盖)基类的虚函数。当通过基类的指针或引用调用虚函数时,会根据指针或引用所指向的对象的实际类型,调用相应的函数。

class Animal {
public:
    virtual void speak() {
        std::cout << "The animal makes a sound." << std::endl;
    }
};
class Dog : public Animal {
public:
    void speak() override {
        std::cout << "The dog barks." << std::endl;
    }
};

在上述代码中,Animal类的speak函数是虚函数,Dog类重写了这个函数。当使用Animal类指针指向Dog类对象,并调用speak函数时,会调用Dog类的speak函数。

(三)纯虚函数与抽象类

纯虚函数是在虚函数的基础上,在函数声明后面加上= 0,表示该函数没有具体的实现,必须在派生类中被重写。包含纯虚函数的类称为抽象类,抽象类不能被实例化,只能作为基类被派生类继承。

class Shape {
public:
    virtual double area() = 0;
};
class Rectangle : public Shape {
private:
    double width;
    double height;
public:
    Rectangle(double w, double h) : width(w), height(h) {}
    double area() override {
        return width * height;
    }
};

在这个例子中,Shape类是抽象类,area函数是纯虚函数。Rectangle类继承自Shape类,并实现了area函数。

(抽象类一般为程序提供统一接口,隔离具体实现)

(四)纯虚析构函数

纯虚析构函数是指在抽象类中定义的纯虚函数作为析构函数。虽然它是纯虚函数,但必须有实现,因为在销毁派生类对象时,会先调用派生类的析构函数,再调用基类的析构函数。

class AbstractClass {
public:
    virtual ~AbstractClass() = 0;
};
AbstractClass::~AbstractClass() {
    // 析构函数实现
}

在上述代码中,AbstractClass定义了纯虚析构函数,并且在类外给出了实现。

在多态编程场景中,当使用基类指针指向派生类对象时,存在一个极易被忽视却又至关重要的内存管理陷阱:如果基类的析构函数不是虚函数,那么在执行delete操作释放该基类指针时,程序只会调用基类自身的析构函数,而派生类的析构函数将被 “跳过” 。这会导致派生类中自定义的资源(如动态分配的内存、打开的文件句柄等)无法得到正确释放,进而引发内存泄漏或其他资源管理错误,具体例子请看下文。

十一、C++ 面向对象编程的细节与易错点

(一)构造函数的细节与陷阱

1.默认构造函数:如果类中没有定义任何构造函数,编译器会自动生成一个默认构造函数,它不接受任何参数,并且会对类中的数据成员进行默认初始化。但一旦程序员定义了任何一个构造函数(哪怕是只有一个带参数的构造函数),编译器就不再生成默认构造函数。例如:

class SimpleClass {
    int data;
public:
    SimpleClass(int value) : data(value) {}
};
// SimpleClass obj; // 这行会编译错误,因为没有默认构造函数

此时若需要无参构造对象,就必须显式定义默认构造函数,避免因疏忽导致的编译错误。

在 C++11 及以后的标准中,引入了= default语法,为默认成员函数的定义提供了更灵活的方式。

class SimpleClass {
    int data;
public:
    SimpleClass() = default; //  C++11 及以后,显式启用编译器生成的默认构造函数
    SimpleClass(int value) : data(value) {}
};

使用= default有几个重要的优势和应用场景:

  • 明确意图,增强代码可读性:当类中已经定义了其他构造函数,如带参数的构造函数SimpleClass(int value) ,编译器不再自动生成默认构造函数。此时通过SimpleClass() = default显式声明,能清晰地向其他开发者表明,该类仍然保留了默认构造的能力,且采用编译器生成的默认实现。这种写法比直接留空构造函数体更具语义表达力,避免他人误解为构造函数中有隐藏的逻辑。

  • 配合特殊成员函数的生成规则:C++ 中特殊成员函数(构造函数、析构函数、拷贝 / 移动构造函数、拷贝 / 移动赋值运算符)的生成规则较为复杂。当一个类定义了某些特殊成员函数后,编译器可能会抑制其他特殊成员函数的自动生成。例如,定义了自定义析构函数后,编译器不会自动生成移动构造函数和移动赋值运算符。通过= default,可以在满足特定条件时,手动要求编译器生成默认版本的特殊成员函数,确保类的行为符合预期。

  • 与模板结合使用:在模板类中,= default语法特别有用。模板类可能需要根据不同的模板参数,灵活决定是否使用默认成员函数。例如:

template <typename T>
class Container {
private:
    T data;
public:
    Container() = default;
    Container(const T& value) : data(value) {}
};

这里Container模板类通过= default为不同类型T的实例,统一使用编译器生成的默认构造函数,减少了模板代码的冗余,同时保证了类的基本构造功能。

需要注意的是,= default只能用于特殊成员函数的定义,并且一旦使用= default,编译器生成的默认实现必须是合法的。例如,如果类中包含的成员变量没有默认构造函数,那么使用= default定义类的默认构造函数将会导致编译错误。此外,= default修饰的成员函数的访问权限由其声明位置决定,比如在private区域声明= default的构造函数,意味着该类不能在外部被默认构造 。

2.拷贝构造函数的隐式生成与深拷贝问题:当程序员没有定义拷贝构造函数时,编译器会生成一个默认的拷贝构造函数,它会进行成员浅拷贝。对于包含指针成员的类,浅拷贝会导致多个对象共享同一块内存,当一个对象销毁释放内存后,其他对象再访问该内存就会出现野指针错误。例如:

class StringWrapper {
    char* str;
public:
    StringWrapper(const char* s) {
        str = new char[strlen(s) + 1];
        strcpy(str, s);
    }
    // 未定义拷贝构造函数,存在隐患
};
StringWrapper a("hello");
StringWrapper b = a; // 这里会调用默认浅拷贝的拷贝构造函数

为避免此类问题,对于有动态内存分配等复杂资源管理的类,必须自定义深拷贝的拷贝构造函数,重新分配内存并复制数据。

3.隐式构造问题:在 C++ 面向对象编程中,explicit关键字为构造函数的使用提供了更精准的控制,它如同一位严格的 “守门人”,能够有效避免一些隐式类型转换带来的潜在问题。

explicit关键字只能用于修饰类的构造函数,其作用是禁止通过该构造函数进行隐式类型转换。在没有explicit修饰时,单参数构造函数(或者除了第一个参数外其他参数都有默认值的构造函数)会自动具备将参数类型转换为类类型的能力。例如:

class Rational {
private:
    int numerator;
    int denominator;
public:
    Rational(int num = 0, int den = 1) : numerator(num), denominator(den) {}
    // 未使用explicit,存在隐式转换可能
};

Rational r1 = 5; // 隐式调用构造函数Rational(5, 1),将int转换为Rational

上述代码中,Rational类的构造函数允许将int类型隐式转换为Rational类型。虽然这种隐式转换在某些场景下提供了便利,但也可能导致代码的语义模糊,甚至引发难以察觉的错误。

为了避免这类问题,可以使用explicit关键字修饰构造函数:

class Rational {
private:
    int numerator;
    int denominator;
public:
    explicit Rational(int num = 0, int den = 1) : numerator(num), denominator(den) {}
};

// Rational r1 = 5; // 编译错误!禁止隐式转换
Rational r2(5); // 显式调用构造函数,正确
Rational r3 = Rational(5); // 显式转换,正确

 当构造函数被explicit修饰后,Rational r1 = 5;这样的隐式转换操作将无法通过编译,必须采用Rational r2(5);Rational r3 = Rational(5);等显式方式调用构造函数。

explicit关键字在函数参数传递和返回值处理中也有着重要意义。例如,当函数接受Rational类型的参数时:

void printRational(const Rational& r) {
    std::cout << r.numerator << "/" << r.denominator << std::endl;
}

printRational(3); // 若Rational构造函数无explicit,隐式转换为Rational(3, 1)后调用
// 若构造函数有explicit,此调用编译错误

通过explicit限制隐式转换,可以让代码的类型转换意图更加清晰,减少因意外转换导致的逻辑错误,提升代码的安全性和可维护性。

需要注意的是,explicit关键字仅对构造函数的隐式转换起作用,对于显式类型转换(如static_cast),即使构造函数被explicit修饰,依然可以进行。此外,explicit修饰的构造函数在使用std::make_sharedstd::make_unique等工厂函数创建对象时,也需要显式传递参数,无法利用隐式转换特性 。

(二)继承中的注意事项

1.基类析构函数与多态析构:当基类指针指向派生类对象,并且基类的析构函数不是虚函数时,通过基类指针删除派生类对象,只会调用基类的析构函数,派生类中新增的资源无法释放,导致内存泄漏。所以,当类作为多态基类时,基类的析构函数必须定义为虚函数。例如:

class BaseClass {
public:
    ~BaseClass() {}
};
class DerivedClass : public BaseClass {
    int* data;
public:
    DerivedClass() { data = new int; }
    ~DerivedClass() { delete data; }
};
BaseClass* ptr = new DerivedClass();
delete ptr; // 若BaseClass析构函数非虚,DerivedClass析构函数不会被调用

BaseClass的析构函数声明为virtual ~BaseClass() {},就能确保派生类对象正确析构。

2.多重继承的复杂性:多重继承允许一个派生类从多个基类继承属性和行为,但它会带来菱形继承等复杂问题。例如:

class A {};
class B : public A {};
class C : public A {};
class D : public B, public C {};

此时D类中会存在两份A类的成员,导致数据冗余和访问歧义。C++ 通过虚基类(virtual public A)的方式来解决菱形继承问题,但虚基类的实现和使用较为复杂,增加了代码的理解和维护难度,因此在实际编程中应谨慎使用多重继承。

(三)多态与虚函数的要点

  1. 虚函数表与动态绑定:C++ 通过虚函数表(vtable)来实现动态多态。每个包含虚函数的类都有一个虚函数表,表中存储着虚函数的指针。当通过基类指针或引用调用虚函数时,程序会根据对象的实际类型,在虚函数表中查找对应的函数指针并调用。理解虚函数表的机制,有助于掌握多态的底层实现原理,也能解释一些特殊情况下的行为,比如在构造函数析构函数中调用虚函数,由于虚函数表尚未完全初始化或已部分销毁,此时调用的是类自身定义的虚函数版本,而非派生类重写的版本

  2. 覆盖的严格要求:派生类重写基类的虚函数时,函数的返回类型、函数名、参数列表必须与基类中的虚函数完全一致,并且访问权限不能比基类更严格。例如基类中虚函数是public的,派生类中重写的函数不能是privateprotected,否则就不是正确的覆盖,无法实现多态效果。

C++ 面向对象编程是一门博大精深的技术,从基础概念到高级特性,每个环节都蕴含着丰富的知识和技巧。在实际项目开发中,程序员需要不断实践、总结,深入理解这些特性的细节和易错点,才能编写出高效、稳定且易于维护的代码。同时,随着 C++ 语言的不断发展,面向对象编程的相关技术也在持续演进,开发者需要保持学习的热情,紧跟技术前沿,充分发挥 C++ 面向对象编程的强大威力。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值