17. 面向对象的特征

一、面向对象的三大特征

  面向对象的三大特征指的是 封装继承多态

  封装(encapsulation,有时称为数据隐藏)是处理对象的一个重要概念。从形式上看,封装就是将数据和行为组合在一个包中,并对对象的使用者隐藏具体的实现方式。

  继承(inheritance)的基本思想是,可以基于已有的类创建新类。继承以存在的类就是复用(继承)这些类的方法,而且可以增加一些新的属性和方法,使新类能够适应新的情况。

  多态(polynirphic)就是一个类的多种形态。

二、封装性

  所谓的封装,就是把客观事物封装成抽象概念的类,并且类可以把自己的数据和方法只向可信的类或对象开放,向没必要开放的类或对象异常信息。封装性就是隐藏内部对象的复杂性,只对外公开简单的接口。便于外部调用,从而提高系统的可扩展性、可维护性。通俗的来说,把该隐藏的隐藏起来,该暴露的暴露出来。

  当我们创建一个类时,我们可以通过“对象.属性”的方式,对对象的属性进行赋值。此时,赋值操作要受到属性的数据类型和存储范围的制约。除此之外,没有其它制约条件。但是,在实际问题中,我们往往需要给属性赋值加入额外的限制条件。这个条件就不能在属性声明时体现,只能通过方法进行限制条件的添加。同时,我们需要避免用户在使用“对象.属性”的方式对属性进行赋值,则需要将属性声明为私有的(private)。此时,针对于属性就体现了封装性。

  使用封装后,确实增加了类的定义的复杂程度,但是它也确保了数据的安全性。我们隐藏了属性名,使调用者无法任意修改对象中的属性。并且我们增加了 getter() 和 setter() 方法,可以很好的控制属性是否为只读的。如果希望属性是只读的,则可以直接去掉 setter() 方法,如果希望属性不能被外部访问,则可以直接去掉 getter() 方法。而且在使用 setter() 方法设置属性时,可以增加数据的验证,确保数据的值是正确的。在使用 getter() 方法获取属性,使用 setter() 方法设置属性时,可以在读取属性和修改属性的同时做一些其它的处理。

  我们将类的属性(xxx)隐藏,同时,提供的公共的方法类获取(getter())和设置(setter(self))此属性的值。在 C++ 中,我们可以使用不同的访问权限来实现封装的效果。

访问权限 含义 作用
public 公共权限 类内和类外都可以访问成员
protected 保护权限 类内可以访问成员,类外不可以访问成员,但子类可以访问父类中的保护内容
private 私有权限 类内可以访问成员,类外不可以访问成员,并且子类不可以访问父类中的私有内容
#include <iostream>

using namespace std;

class Person
{
    // 属性
    string name;
    int age;

public:             // 公共权限
    // 行为
    string getName()
    {
        return name;
    }

    void setName(string n)
    {
        name = n;
    }

    int getAge()
    {
        return age;
    }

    void setAge(int a)
    {
        if (a < 0)
        {
            cout << "年龄不合法" << endl;
            return;
        }
  
        age = a;
    }

    void showInfo()
    {
        cout << "{name: " << name << ", age: " << age << "}\n";
    }
};

  如果我们直接访问类中的私有属性,编译器会报错误。此时,我们可以通过公共的 getter() 方法和 setter() 方法调用类的私有属性:

int main(void)
{
    // 通过类创建一个具体的对象
    Person p1;

    // 给对象的属性进行赋值
    p1.setName("Sakura");
    p1.setAge(10);

    cout << "{name: " << p1.getName() << ", age: " << p1.getAge() << "}\n";
    p1.showInfo();
}

类中,成员的默认权限是私有权限。

三、继承性

3.1、什么是继承性

  继承,其基本思想就是基于某个父类的扩展,制定出一个新的子类,子类可以继承父类原有的属性和方法,也可以增加原来父类所不具备的属性和方法,或者直接重写父类中的某些方法。通过继承可以直接让子类获取父类的方法和属性,避免编写重复性的代码,并且也符合 OCP 原则。所以,我们经常需要通过继承来对一个类进行扩展。继承的出现让类与类之间产生了 is-a 的关系。

  在 C++ 中,继承性的格式如下:

class 子类名 : 权限修饰符 父类名
{
    // 类的成员
}

  一旦子类继承父类之后,子类就获取父类中声明的所有的结构:成员变量和成员函数;子类继承父类以后,还可以声明自己特有的成员变量和成员函数,实现功能的拓展;我们可以在子类中使用 using 类名::构造函数名; 来使用父类中的构造函数简化代码。

  新建一个 animal.h 头文件用来保存父类的声明。

#pragma once
#include <iostream>

using namespace std;

class Animal 
{
private:
    string name;

public:
    Animal(void);
    Animal(string name);

    void run(void);
    void sleep(void);

    string getName(void);
    void setName(string name);
};

  新建一个 animal.cpp 文件用来实现 animal.h 头文件中声明的方法。

#include "animal.h"

Animal::Animal(void) : name("") {}
Animal::Animal(string name) : name(name) {}
 
void Animal::run(void)
{
    cout << "动物在跑" << endl;
}
   
void Animal::sleep(void)
{
    cout << "动物在睡觉" << endl;
}

string Animal::getName(void)
{
    return name;
}

void Animal::setName(string name)
{
    this->name = name;
}

  新建一个 dog.h 头文件用来保存子类的声明。

#pragma once
#include <iostream>
#include "animal.cpp"

using namespace std;

class Dog : public Animal
{
public:
    // 使用父类的构造函数
    using Animal::Animal;

    void bark(void);
};

  新建一个 dog.cpp 文件用来实现 dog.h 头文件中声明的方法。

#include "dog.h"

void Dog::bark(void)
{
    cout << getName() << "在叫:汪汪汪" << endl;
}

  在包含 main() 函数的文件中包含刚才定义的头文件,然后使用。

#include <iostream>
#include "dog.cpp"

using namespace std;

int main(void)
{
    Dog dog("旺财");
  
    dog.run();
    dog.sleep();
    dog.bark();

    return 0;
}

3.2、继承方式

  在 C++ 中,继承主要分为三种:

继承方式

#include <iostream>

using namespace std;

// 父类A
class A
{
public:
    int a;
protected:
    int b;
private:
    int c;
};

// 类B公共继承类A
class B: public A
{
public:
    void func(void)
    {
        // 父类中公共权限成员到子类中依然是公共权限
        a = 10;
        // 父类中保护权限成员到子类中依然是保护权限
        b = 10;
        // 父类中的私有权限成员子类访问不到
        // c = 10;
    }
};

// 类C保护继承类A
class C: protected A
{
public:
    void func(void)
    {
        // 父类中公共权限成员到子类中变成保护权限
        a = 20;
        // 父类中保护权限成员到子类中依然是保护权限
        b = 20;
        // 父类中的私有权限成员子类访问不到
        // c = 20;
    }
};

// 类D私有继承类A
class D: private A
{
public:
    void func(void)
    {
        // 父类中公共权限成员到子类中变成私有权限
        a = 30;
        // 父类中保护权限成员到子类中变为私有权限
        b = 30;
        // 父类中的私有权限成员子类访问不到
        // c = 30;
    }
};

// 类E公共继承类C,而类C又保护继承类A
class E: public C
{
public:
    void func(void)
    {
        // 子类中可以访问父类中保护权限的内容
        a = 40;
        b = 40;
        // 父类中的私有权限成员子类访问不到
        // c = 40;
    }
};

// 类F公共继承类D,而类D又私有继承类A
class F: public D
{
public:
    void func(void)
    {
        // 子类中无法访问父类中私有的内容
        // a = 20;
        // b = 20;
        // c = 20;
    }
};

int main(void)
{
    B b;
    b.a = 100;
    // 到B类中b是保护权限,类外无法访问
    // b.b = 100;

    C c;
    // 到C类中a是保护权限,类外无法访问
    // c.a = 200;

    D d;
    // 到D类中a是私有权限,类外无法访问
    // d.a = 300;

    cout <<"size of A: " << sizeof(A) << endl;
    cout <<"size of B: " << sizeof(B) << endl;
    cout <<"size of C: " << sizeof(C) << endl;
    cout <<"size of D: " << sizeof(D) << endl;

    return 0;
}

父类中所有非静态成员属性都会被子类继承下去,编译器私有成员的属性被编译器隐藏了,因此是访问不到的,但是确实被继承下去了。

3.3、子类创建的流程

  当创建子类对象时,先调用父类中的构造函数,再调用子类中的构造函数。销毁子类对象时,先调用子类中的析构函数,再调用父类中的析构函数。

#include <iostream>

using namespace std;

// 父类A
class SuperClass
{
public:
    SuperClass(void)
    {
        cout << "SuperClass()" << endl;
    }

    ~SuperClass(void)
    {
        cout << "~SuperClass()" << endl;
    }
};

class SubClass : public SuperClass
{
public:
    SubClass(void)
    {
        cout << "SubClass()" << endl;
    }

    ~SubClass(void)
    {
        cout << "~SubClass()" << endl;
    }
};

int main(void)
{
    SubClass subClass;

    return 0;
}

3.4、同名成员的处理

  如果子类和父类有同名的变量。子类对象调用的是子类中的同名成员。如果需要子类对象访问父类中同成员,需要加作用域。如果子类中出现和父类中同名的成员函数,子类的同名函数会隐藏父类中所有同名的成员函数。如果想访问父类中被隐藏的同名成员函数,需要加作用域。

#include <iostream>

using namespace std;

// 父类A
class SuperClass
{
public:
    int num;

    SuperClass(void) : num(100) {}

    void func(void)
    {
        cout << "SuperClass类中的num属性:" << num << endl;
    }

    void func(int num)
    {
        this->num = num;
        cout << "SuperClass类中的num属性:" << this->num << endl;
    }
};

class SubClass : public SuperClass
{
public:
    int num;

    SubClass(void) : num(200) {}

    void func(void)
    {
        cout << "SubClass类中的num属性:" << num << endl;
    }
};

int main(void)
{
    SubClass subClass;

    // 子类对象调用的是子类中的同名成员变量
    cout << "SubClass中的num属性:" << subClass.num << endl;
    // 如果需要子类对象访问父类中同名成员变量,需要加作用域
    cout << "SubClass中访问父类中的num属性:" << subClass.SuperClass::num << endl;

    // 字节调用的是子类中的同名成员函数
    subClass.func();
    // 如果需要子类对象访问父类中同名成员,需要加作用域
    subClass.SuperClass::func();

    // 如果子类中出现和父类中同名的成员函数,子类的同名成员会隐藏父类中所有同名的成员函数
    // 如果想访问父类中被隐藏的同名成员函数,需要加作用域
    // subClass.func(300);
    subClass.SuperClass::func(300);

    return 0;
}

3.5、多重继承

  在 C++ 中是支持多重继承的,也就是我们可以为一个类同时指定多个父类。我们可以在类名的 : 后面添加多个类,来实现多重继承。多重继承会使子类同时拥有多个父类,并且或获取所有父类中的方法。如果多个父类中有同名的成员,需要使用作用域加以区分。

#include <iostream>

using namespace std;

// 父类A
class SuperClass1
{
public:
    int num;

    SuperClass1(void) : num(100) {}

    void func(void)
    {
        cout << "SuperClass1类中的num属性:" << num << endl;
    }
};

class SuperClass2
{
public:
    int num;

    SuperClass2(void) : num(200) {}

    void func(void)
    {
        cout << "SuperClass2类中的num属性:" << num << endl;
    }
};

class SubClass : public SuperClass1, public SuperClass2
{

};

int main(void)
{
    SubClass subClass;

    cout << "size of SubClass: " << sizeof(SubClass) << endl;
    cout << "size of SuperClass1: " << sizeof(SuperClass1) << endl;
    cout << "size of SuperClass2: " << sizeof(SuperClass2) << endl;

    // 当父类中出现同名成员,需要加作用域区分
    // cout << "subClass.num: " << subClass.num << endl;
    cout << "subClass.SuperClass1::num: " << subClass.SuperClass1::num << endl;
    cout << "subClass.SuperClass2::num: " << subClass.SuperClass2::num << endl;

    return 0;
}

3.6、菱形问题

  在 C++ 中子类可以同时继承多个父类的属性,这样可以最大限度地重用代码,但是使用多继承会导致扩展性变差,并且有可能会导致棱形问题(钻石问题)。棱形问题(钻石问题)指的是一个子类继承的多个父类汇集到一个父类的身上。
棱形问题

  当发生菱形继承并且多个父类拥有相同的数据,需要加以作用域区分。这份数据,我们只需要一份即可,菱形继承导致数据有两份,造成资源浪费。利用虚继承可以解决菱形问题。

#include <iostream>

using namespace std;

class A
{
public:
    int num;
};

// 利用虚继承可以解决菱形问题
// 在继承之前加上关键字virtual变为虚继承
// 变成虚继承后,A类为虚基类
class B : virtual public A {};

class C : virtual public A {};

class D : public B, public C {};

int main(void)
{
    D d;

    // 当菱形继承时,两个父类拥有相同的数据,需要加以作用域区分
    // 菱形继承导致资源有两份,资源浪费
    d.B::num = 10;
    d.C::num = 20;

    cout << "d.B::num: " << d.B::num << endl;
    cout << "d.C::num: " << d.C::num << endl;

    cout << "d.num: " << d.num << endl;

    return 0;
}

3.7、final关键字的使用

  C++ 中新增加了 final 关键字来 限定某个类不能被继承,或者 某个虚函数不能被重写。final 关键字 只能用来修饰类或者虚函数

  如果使用 final 修饰 虚函数,表示 该虚函数不能被重写。这时,我们只需要把 final 关键字放到函数后面即可。

class Base
{
public:
    virtual void test(void);
};

void Base::test(void)
{
    cout << "Base::test()" << endl;
}

class Child : public Base
{
public:
    // 使用final修饰父类中的虚函数,表示该类的子类该不能重写该函数
    void test(void) final;
};

void Child::test(void)
{
    cout << "Child::test()" << endl;
}

  如果使用 final 修饰 ,表示 该类不能被继承。这时,我们只需要把 final 关键字放到类名后即可。

class Base
{
public:
    virtual void test(void);
};

void Base::test(void)
{
    cout << "Base::test()" << endl;
}

class Child final: public Base
{
public:
    // 使用final修饰父类中的虚函数,表示该类的子类该不能重写该函数
    void test(void) override final;
};

void Child::test(void)
{
    cout << "Child::test()" << endl;
}

3.8、override关键字的使用

  override 关键字确保在派生类中声明的函数与基类函数有相同的签名,同时也明确表明将会重写基类的虚函数,这样就可以保证重写的虚函数的正确性,也提高了代码的可读性。和 final 关键字一样,override 关键字要写在方法的后面。

class Base
{
public:
    virtual void test(void);
};

void Base::test(void)
{
    cout << "Base::test()" << endl;
}

class Child final: public Base
{
public:
    void test(void) override;
};

void Child::test(void)
{
    cout << "Child::test()" << endl;
}

四、多态性

4.1、什么是多态性

  多态性指的是一个事物的多种形态;同样的行为(函数),传入不同的对象,得到不同的状态;多态性指的是可以在不考虑对象具体类型的情况下而直接使用对象。多态分为两类:静态多态动态多态

  • 静态多态:函数重载和运算符重载属于静态多态,复用函数名。
  • 动态多态:派生类和虚函数实现运行时多态。

  静态多态和动态多态的区别如下:

  • 静态多态的函数地址早绑定 —— 编译阶段确定函数地址
  • 动态多态的函数地址晚绑定 —— 运行阶段确定函数地址

  动态多态的需要满足如下条件:

  1. 有继承关系。
  2. 子类重写父类中的虚函数。

  如果我们想要使用动态多态,需要 父类的指针或引用指向子类对象。基类指针可以在不进行显示类型转换的情况下指向派生类对象。基类指针只能调用基类方法。这是因为,派生类继承了基类的方法,所以这样做没有问题。

#include <iostream>

using namespace std;

class Animal
{
public:
    // 虚函数,可以实现地址晚绑定
    virtual void speak(void)
    {
        cout << "动物在叫!" << endl;
    }
};

class Dog : public Animal
{
public:
    void speak(void)
    {
        cout << "汪汪汪!" << endl;
    }
};

class Cat : public Animal
{
    void speak(void)
    {
        cout << "喵喵喵!" << endl;
    }
};

// 动态多态的使用:父类的指针或引用指向子类对象
void make_noise(Animal & animal)
{
    animal.speak();
}

int main(void)
{
    Animal animal;
    Dog dog;
    Cat cat;

    make_noise(animal);
    make_noise(dog);
    make_noise(cat);
}

4.2、虚函数的工作原理

  C++ 规定了虚函数的定义,但将实现方法留给了编译器开发者。通常,编译器处理虚函数的方法是:给每个对象添加一个隐藏成员。隐藏成员中保留了一个指向函数地址数据的指针。这种数组称为 虚函数表(virtual function table, vtbl)。虚函数表中存储了为类对象进行声明的虚函数的地址。

  例如,基类对象包含一个指针,该指针指向基类中所有虚函数的地址表。派生类对象将包含一个指向独立地址表的指针。如果派生类提供了虚函数的新定义,该虚函数表中将保存新函数的地址。如果派生类没有重新定义虚函数,该虚函数表将保存函数原始版本的地址。如果派生类定义了新的虚函数,则该函数的地址也将被添加到虚函数表中。

无论类中包含的虚函数是 1 个还是 10 个,都需要在对象中添加 1 个地址成员,指向虚函数表,只是指向的虚函数表的大小不同。

  在调用虚函数时,程序将查看存储在对象中的虚函数表,然后转向相应的函数地址表。如果使用类声明中定义的第一个虚函数,则程序将使用数据中的第一个函数地址,并执行具有该地址的函数。如果使用类声明中第三个虚函数,程序将使用地址为数据中第三个元素的函数。

  总之,使用虚函数时,在内存和执行速度方面有一定的成本,包括:

  • 每个对象都将增加,增大量为存储地址空间。
  • 对于每个类,编译器都创建一个虚函数地址表(数组)。
  • 对于每个函数调用,都需要执行一项额外的操作,即到表中查找地址。

4.3、纯虚函数与抽象类

  在多态中,通常父类中虚函数的实现是无意义的,主要都是调用子类重写的内容。因此,可以将虚函数改为 纯虚函数。纯虚函数的语法如下:

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

  当类中有了虚函数后,这个类也称为 抽象类。抽象类无法实例化对象,并且子类必须重写抽象类中的纯虚函数,否则子类也属于抽象类。

#include <iostream>

using namespace std;

// 只要有一个纯虚函数,这个类就称为抽象类
class Animal
{
public:
    // 纯虚函数
    virtual void speak(void) = 0;
};

// 子类继承抽象类后,必须重写纯虚函数,否则这个子类也是抽象类
class Dog : public Animal
{
public:
    void speak(void)
    {
        cout << "汪汪汪!" << endl;
    }
};

class Cat : public Animal
{
    void speak(void)
    {
        cout << "喵喵喵!" << endl;
    }
};

// 动态多态的使用:父类的指针或引用指向子类对象
void make_noise(Animal & animal)
{
    animal.speak();
}

int main(void)
{
    // 抽象类无法实例化对象
    // Animal animal;
    Dog dog;
    Cat cat;

    // make_noise(animal);
    make_noise(dog);
    make_noise(cat);
}

4.4、虚析构和纯虚析构

  多态使用时,如果子类由属性开辟到堆区,那么父类指针在释放时无法调用子类的析构代码,此时,需要将父类中的析构函数改为虚析构或者纯虚析构。虚析构和纯虚析构都可以解决父类指针释放子类对象,都需要具体的函数实现。如果是纯虚析构,该类属于抽象类,无法实例化对象。

  虚析构:

virtual ~类名(){}

  纯虚析构:

virtual ~类名() = 0;
#include <iostream>

using namespace std;

// 只要有一个纯虚函数,这个类就称为抽象类
class Animal
{
public:
    Animal(void)
    {
        cout << "Animal的构造函数调用" << endl;
    }

    // 父类的指针在析构的时候,不会调用子类中的析构函数,导致子类如果有堆区属性,出现内存泄露
    // 利用虚析构可以解决父类指着释放子类对象时不干净的问题
    virtual ~Animal(void)
    {
        cout << "Animal的析构函数调用" << endl;
    }

    // 纯虚函数
    virtual void speak(void) = 0;
};

// 子类继承抽象类后,必须重写纯虚函数,否则这个子类也是抽象类
class Cat : public Animal
{
public:
    string * name;

    Cat(void) {}

    Cat(string name)
    {
        cout << "Cat的构造函数调用" << endl;
        this->name = new string(name);
    }

    ~Cat(void)
    {
        if (name != nullptr)
        {
            cout << "Cat的析构函数调用" << endl;
            delete name;
            name = nullptr;
        }
    }

    void speak(void)
    {
        cout << *name << ": 喵喵喵!" << endl;
    }
};

int main(void)
{
    Animal *animal = new Cat("Tom");
    animal->speak();
    delete animal;
}

  如果使用纯虚析构,既要声明,也要具体实现,这点与纯虚函数不同。有了纯析构函数之后,这个类也属于抽象类,无法实例化对象。

#include <iostream>

using namespace std;

// 只要有一个纯虚函数,这个类就称为抽象类
class Animal
{
public:
    Animal(void)
    {
        cout << "Animal的构造函数调用" << endl;
    }

    // 纯虚析构
    virtual ~Animal(void) = 0;

    // 纯虚函数
    virtual void speak(void) = 0;
};

Animal::~Animal(void)
{
    cout << "Animal的纯虚析构函数调用" << endl;
}

// 子类继承抽象类后,必须重写纯虚函数,否则这个子类也是抽象类
class Cat : public Animal
{
public:
    string * name;

    Cat(void) {}

    Cat(string name)
    {
        cout << "Cat的构造函数调用" << endl;
        this->name = new string(name);
    }

    ~Cat(void)
    {
        if (name != nullptr)
        {
            cout << "Cat的析构函数调用" << endl;
            delete name;
            name = nullptr;
        }
    }

    void speak(void)
    {
        cout << *name << ": 喵喵喵!" << endl;
    }
};

int main(void)
{
    Animal *animal = new Cat("Tom");
    animal->speak();
    delete animal;
}

虚析构和纯析构都是用来解决通过父类指针释放子类对象的问题。

如果子类中没有堆区数据,可以不写虚析构和纯虚析构。

用于纯虚析构函数的类也属于抽象类,无法实例化对象。

posted @ 2023-04-30 20:01  星光樱梦  阅读(34)  评论(0编辑  收藏  举报