C++ 面向对象

9/5/2019 面向对象

# 抽象

# 面向对象抽象机制

所有的编程语言都提供抽象机制。从某种程度上来说,问题的复杂度直接取决于抽象的类型与质量。这里的“类型”是指抽象的内容是什么?汇编语言是对底层机器的轻微抽象,命令式语言(C, FORTRAN, BASIC)是对汇编语言的抽象。与汇编语言相比,这类语言已经有了长足的改进,但他们的抽象原理依然要求我们这种考虑计算机的结构,而给问题的结构。

程序员必须要在机器模型(“解决方案空间”)和实际解决的问题模型(“问题空间”)之间建造一种关联。相比之前的抽象过程,面向对象的程序设计则允许我们根据问题来描述问题,而不是根据运行解决方案的计算机。

程序员可以利用一些工具,来表达“问题空间”中的元素(对象)。由于这种表达非常具有普遍性,所以不必受限于特别类型的问题(复用)。当然,还有一些在问题空间没有的对应的对象体。通过添加新的对象类型(类),程序可进行灵活的调整,以便与特定的类型配合。所以,在阅读描述解决方案的代码时,其实也是在阅读问题的表述。

Grady Booch 提供了对对象的更简洁的描述:一个对象具有自己的状态,行为和标识。这意味着对象有自己的内部数据(提供状态)、方法 (产生行为),并彼此区分(每个对象在内存中都有唯一的地址)。

# 类与对象关系

根据上述,简化得到说明:

  • - 是对问题模型中一类事物直接抽象的结果,是该类事物在程序中的表达。
  • 对象 - 问题空间中的元素以及它们在解决方案空间的表示,是类的具体表现。

在现实世界,类与对象 的关系就是“汽车与一辆汽车”的关系,是“奶牛与一头奶牛”的关系。当我们看到一辆汽车,我们可以说,“这是一辆汽车”(对象描述)和这是“这是汽车”(类描述),而“这是卡车”或“这是一头奶牛”等等描述无疑就是指鹿为马,在程序世界中这是不被允许的。

即:

类与对象的关系 - 即类型与类型实体的关系。

以上面的汽车为例,我们来描述一下类与对象的基本特征。在谈论一辆汽车的时候,我们会这样描述它:“这辆法拉利 812 Superfast 仅仅 2.9s 的百公里加速度,轻松让你体验在踩下油门那一刻的无限快感。”根据这段描述,我们就可以得到,汽车的品牌,型号,百公里加速度等参数,和踩油门等可执行的操作。

在我们试图根据描述,在程序世界抽象出车并描述这辆车的过程中,我们就将这些参数称作抽象作类的属性,将可执行的操作抽象成方法。

class Car {
    // 方法 - 踩油门
    void stepOnGas() {
        std::cout << "踩下油门";
    }

    std::string brand;          // 属性 - 品牌
    std::string model;          // 属性 - 型号
    double acceleration;        // 属性 - 百公里加速度
};
1
2
3
4
5
6
7
8
9
10

在使用上述的类型获得具体的汽车时,就可以使用成员运算符 .-> (仅针对指针引用)来进行如下操作。

Car foo;
foo.brand = "ferrari";        // 设置品牌
foo.model = "812 Superfast";  // 设置型号
foo.acceleration = 2.9;       // 设置加速度 

foo.stepOnGas();   // 描述踩油门的操作

std::cout << "brand: " << foo.brand << std::endl;
std::cout << "model: " << foo.model << std::endl;
std::cout << "acceleration: " << foo.acceleration << std::endl;
1
2
3
4
5
6
7
8
9
10

输出结果:

踩下油门
brand: ferrari
model: 812 Superfast
acceleration: 2.9
1
2
3
4

# 封装

程序员在使用面向对象思想,编写类的过程中,实际是将事物的特征及动作形成一个整体,事物的特征和动作成为了类中的属性和方法。类仅向外部调用者公开必要的内容,而隐藏内部实现的细节,这就是封装

封装有效地避免了该类被错误地使用与更改,减少了程序出错的可能。如果类对调用者暴露了所有细节给调用者,那么有些规则就容易被篡改。类库无法确认调用者是否会按照自己所希望的方式来调用,甚至会使类或对象被篡改,造成潜在的安全问题。“访问控制”应运而生,并从根本上阻止这种情况的发生。

使用访问控制主要是为了:

  1. 不让调用者调用他们不应该接触的部分
  2. 允许类创建者在不修改外部接口的情况下完善更新该工具库

针对类的属性和方法,C++ 语言中提供了三种显式权限修饰关键字。

  • private - 仅允许类的内部访问,这也是默认情况(不添加权限修饰关键字的情况)
  • protected - 仅允许类的内部及其子类访问
  • public - 允许外部访问

这里我们再对上一节中出现的例子进行优化。


class Car {
public:
    // 构造函数
    Car(std::string brand, std::string model, double acce)
            : brand(brand), model(model), acceleration(acce) {}

    // 方法 - 踩油门
    void stepOnGas() {
        std::cout << "踩下油门";
    }

    std::string getBrand() const {
        return brand;
    }

    std::string getModel() const {
        return model;
    }

    double getAcceleration() const {
        return acceleration;
    }

private:  // 隐藏内部细节
    std::string brand;          // 属性 - 品牌
    std::string model;          // 属性 - 型号
    double acceleration;        // 属性 - 百公里加速度
};

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

上述代码,将三个属性设置成了私有。此时,再尝试在类外部直接修改或访问其私有属性,会报编译错误

Car foo;
foo.brand = "ferrari";        // error: 'std::__cxx11::string Car::brand' is private within this context

std::cout << "brand: " << foo.brand << std::endl; // error: 'std::__cxx11::string Car::brand' is private within this context
1
2
3
4

正确的使用方式应该是:

Car foo = Car("ferrari", "812 Superfast", 2.9);

std::cout << "brand: " << foo.getBrand() << std::endl;
std::cout << "model: " << foo.getModel() << std::endl;
std::cout << "acceleration: " << foo.getAcceleration() << std::endl;

foo.stepOnGas();
1
2
3
4
5
6
7

# 继承

类的概念组成了的编程语言的基本单元。但新的问题产生了,在创建了一个类之后,即使一个新类与之具有相似的功能,但我们仍然必须重新创建一个新类。但我们若能利用现成的数据类型,对其进行克隆,在根据需求进行修改,情况就变得理想得多。

“继承”正是解决了这个问题。继承实际是一种面向对象语言所提供的代码复用机制。继承并不完全等同于“克隆”。在继承关系中,我们通常称被继承的类为基类,继承得到的类为派生类。

基类派生类

继承通过基类和派生类的概念来表达类与类之间的相似性。基类包含派生自它的类型之间共享的所有特征和行为,尽管私有类型被隐藏起来并且不可访问。创建基类以表示思想的核心。从基类中派生出其他类型来表示实现该核心的不同方式。类型的层次结构体现了其间的相似性和差异性。

比如,对一个形状,基类是 Shape , 每个形状都有其颜色,大小,位置等,每个形状都可以绘制,擦除,移动,着色等等。由此,可以派生出具体类型的形状--圆形,正方形,三角形,每个形状都有附加的特征或行为。在形状中,某些形状可以翻转,某些的行为可能不同,比如形状面积的计算。

Shape

注意,C++ 是一门支持多继承的语言

C++ 继承中的访问权限管理:

类继承的访问权限控制

横轴表示基类的权限,纵轴表示子类的继承权限,中间为在派生类中的访问权限。上表要记有些麻烦。但细心的伙伴可能已经发现,基类的属性方法,在派生类中的访问权限实际是由其在基类中的权限与子类的继承权限中较安全的一个来决定的(安全性: private > protected > public)。

我们仍然以前“形状”的例子来展示继承的 C++ 实现,以及继承权限的问题(这里并不会涉及到多继承,多继承之后有机会再讲)。

下面一个类是 Shape 类,为基类。其中成员属性和方法,关于颜色的为 public , 关于位置的为 protected , 关于大小或显隐的为 private 。代码中关于权限的注释的含义是: 继承权限 + 原方法在基类中的权限 = 在当前类的权限

如果已经比较了解继承相关的信息,下面的代码部分,可以跳过。

// Shape.h

#include <iostream>
#include <string>

class Shape {
public:
    std::string color;   // 颜色

    Shape() : color("blue"), size(0), positionX(0), positionY(0) {
        std::cout << "Shape constructor without parameters" << std::endl;
    }

    std::string getColor() {
        return color;
    }

    void setColor(std::string c) {
        this->color = std::move(c);
    }

protected:
    double positionX;    // 位置 x
    double positionY;    // 位置 y
    void move(double offsetX, double offsetY) {
        positionX += offsetX;
        positionY += offsetY;
    }

private:
    double size;         // 大小

    void draw() {
        std::cout << "draw Shape" << std::endl;
    }

    void erase() {
        std::cout << "erase Shape" << std::endl;
    }
};

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// Circle.h 其中 Circle 类 public 继承 Shape 类

#include "Shape.h"

 class Circle : public Shape {
 public:
    Circle() {
        // public 继承

        // public + public = public
        this->setColor("circle-color");
        std::cout << this->getColor() << std::endl;
        this->color = "circle-color-2";
        std::cout << this->getColor() << std::endl;

        // public + protected = protected
        this->positionX = 10;
        this->positionY = 10;
        this->move(10,10);
        std::cout << "position: " << positionX << " " << positionY << std::endl;

        // public + private = 不可访问,会出现编译错误
        // Error 注释掉是为了方便测试时编译通过
        // std::cout << this->size << std::endl;
        // this->draw();
        // this->erase();
    }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Square.h 其中 Square 类 protected 继承 Shape 类

#include "Shape.h"

class Square : protected Shape {
public:
    Square() {
        // protected 继承

        // protected + public = protected
        this->setColor("Square-color");
        std::cout << this->getColor() << std::endl;
        this->color = "Square-color-2";
        std::cout << this->getColor() << std::endl;

        // protected + protected = protected
        this->positionX = 20;
        this->positionY = 20;
        this->move(10, 10);
        std::cout << "position: " << positionX << " " << positionY << std::endl;

        // protected + private = 不可访问,会出现编译错误
        // Error 注释掉是为了方便测试时编译通过
        // std::cout << this->size << std::endl;
        // this->draw();
        // this->erase();
    }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Triangle.h 其中 Triangle 类 private 继承 Shape 类

#include "Shape.h"

class Triangle : private Shape {
public:
    Triangle() {
        // private 继承

        // private + public = private
        this->setColor("Triangle-color");
        std::cout << this->getColor() << std::endl;
        this->color = "Triangle-color-2";
        std::cout << this->getColor() << std::endl;

        // private + protected = private
        this->positionX = 30;
        this->positionY = 30;
        this->move(10, 10);
        std::cout << "position: " << positionX << " " << positionY << std::endl;

        // private + private =  不可访问,会出现编译错误
        // Error 注释掉是为了方便测试时编译通过
        // std::cout << this->size << std::endl;
        // this->draw();
        // this->erase();
    }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// RightTriangle.h 其中 RightTriangle 类 public 继承 Triangle

#include "Triangle.h"

class RightTriangle: public Triangle {
};

1
2
3
4
5
6
7

Circle cc;
// cc 可见属性方法:
cc.setColor("cc-color");
std::cout << "cc.getColor(): " << cc.getColor() << std::endl;
std::cout << "cc.color: " << cc.color << std::endl;

std::cout << std::endl << std::endl;

Square ss;
// cc 可见属性方法: 无

std::cout << std::endl << std::endl;

Triangle tt;
// tt 可见属性方法:无

std::cout << std::endl << std::endl;

RightTriangle rt;
// rt 可见属性方法:无

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

控制台得到输出

Shape constructor without parameters
circle-color
circle-color-2
position: 20 20
cc.getColor(): cc-color
cc.color: cc-color


Shape constructor without parameters
Square-color
Square-color-2
position: 30 30


Shape constructor without parameters
triangle
Triangle-color
Triangle-color-2
position: 40 40


Shape constructor without parameters
triangle
Triangle-color
Triangle-color-2
position: 40 40

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

由此可见,当基类中的属性和方法权限为 private 时, 无论子类或是外部,都无法调用。其他情况下,以继承权限和原方法在基类中的权限中较安全的一个来算。

有没有伙伴好奇为什么我要在基类 Shape 中输出一段文字 Shape constructor without parameters , 从结果我们可以看到,每次在实例化子类时,该文字都被输出了一遍,即使实例化的是子类的子类 RightTriangle。其实,当我们在子类被实例化时,系统会隐式地先实例化父类的对象。在 Java 或 JavaScript等语言,甚至有 super 关键字获取父类对象。

还有更多知识值得去说的,如果有机会(我有更深刻的理解),之后慢慢写。

# 多态

我们在处理类的层次结构时,通常把一个对象看成是它所属的基类,而不是把它当成具体类。通过这种方式,我们可以编写出不局限于特定类型的代码。在上个“形状”的例子中,“方法”(method)操纵的是通用“形状”,而不关心它们是“圆”、“正方形”、“三角形”还是某种尚未定义的形状。所有的形状都可以被绘制、擦除和移动,因此“方法”向其中的任何代表“形状”的对象发送消息都不必担心对象如何处理信息。

这样的代码不会受添加的新类型影响,并且添加新类型是扩展面向对象程序以处理新情况的常用方法。 例如,你可以通过通用的“形状”基类派生出新的“五角形”形状的子类,而不需要修改通用"形状"基类的方法。通过派生新的子类来扩展设计的这种能力是封装变化的基本方法之一。

这种能力改善了我们的设计,且减少了软件的维护代价。如果我们把派生的对象类型统一看成是它本身的基类(“圆”当作“形状”,“自行车”当作“车”,“鸬鹚”当作“鸟”等等),编译器(compiler)在编译时期就无法准确地知道什么“形状”被擦除,哪一种“车”在行驶,或者是哪种“鸟”在飞行。这就是关键所在:当程序接收这种消息时,程序员并不想知道哪段代码会被执行。“绘图”的方法可以平等地应用到每种可能的“形状”上,形状会依据自身的具体类型执行恰当的代码。

在上面几节的示例代码中,我们常常称 ShapeTriangle等类型被称为具体类型。之所以被称为具体类型(concrete type),是因为他们的表现形式属于定义的一部分。在这一点上,他们与内置类型很类似。相反,抽象类型(abstract type)则将使用者与类的实现细节完全隔离开来。为了做到这一点,我们分离接口与表现形式并且放弃了纯局部变量。

首先,我们为 Shape 类设计接口(相对之上版本做了简化),现在的 Shape 类可以看成是比 之前的 Shape 更抽象的一个版本。

// Shape.h

class Shape {
public:
    virtual void draw() = 0;  // 纯虚函数

    virtual void erase() = 0;
    
    virtual void move(double, double) = 0; // 

    virtual ~Shape() {}       // 虚析构函数很重要,之后有机会再讲。
};
1
2
3
4
5
6
7
8
9
10
11
12

对于后面定义的那些特定形状来说,上面的 Shape 类是一个纯粹的接口。关键字 virtual 的意思是“可能随后在其派生类中重新定义”。这种用关键字 virtual 声明的函数被称作“虚函数”。Shape 的派生类负责为其提供具体的接口实现。看起来有点儿奇怪的 =0 说明该函数是“纯虚函数”,意味着其派生类必须定义这个函数。因此,我们不能用 Shape 类来实例化一个对象,它只是作为接口出现,其派生类才负责具体实现其方法。含有纯虚函数的类称为“抽象类”。

现在我们可以这么使用 Shape 类:

void use(Shape& s) {
    s.draw();
    s.move(100,100);
}
1
2
3
4

注意看,上面的 use() 是如何在完全忽视实现细节的情况下使用的 Shape 接口的。它使用了 draw()move(),却根本不知道是哪个类型实现了他们。如果一个类负责为其他一些类提供接口,那么我们把前者称为“多态类型”。

我们现在重构一下之前的 Circle 类。

class Circle : public Shape {
public:
    Circle(double c, double r)
        : center(c), radius(r) {}

    void draw() {
        // code ...
    }

    void erase() {
        // code ...
    }
    
    void move(double, double) {
        // code ...
    }

    ~Shape() {}       // 虚析构函数很重要,之后有机会再讲。

private:
    double center;    // 圆心
    double radius;    // 半径
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

我们现在来使用上面的 Circle 类。

Circle cc { 10, 10 };
use(cc);
1
2

我们在调用 Shape 类方法的时候我们不需要知道具体执行了哪部分代码,那我们就能添加一个新的不同执行方式的子类而不需要更改调用它的方法。

思考:那么编译器在不确定该执行哪部分代码时是怎么做的呢? (提示:虚函数表)欲知后事如何,且听下回分解

# 总结

(个人总结)

  • 合理抽象是编写面向对象程序的基础
  • 封装保证了程序的安全性
  • 继承是一种代码的重用机制,同时,也是 C++ 语言世界中多态的基础
  • 真正面向对象的魅力来源于多态

# 引用

# 留言

这是我目前对“面向对象几大特性”的理解分享,其中难免会有我所没有理解透的,或者误解的内容。随着我的不断学习,内容可能也会不断更新。如果文内有写的不对的地方,还请各位看官留言反馈😂,我会立即修正,并加强学习。

Last Updated: 10/23/2021, 4:31:30 PM