跳转至

面向对象程序设计(OOP)

  • OOP中含有下面三个重要思想:封装 继承 多态

封装

为什么要进行封装

在C语言的编程中,可以通过链表或数组实现一个线性表,并对其编写增删改查等函数。尽管通过两种方式编写的是同一种抽象数据类型 (ADT, Abstract Data Type),他们的操作函数却不能同名,影响代码编写的效率。

例如,我们想在一个代码中同时使用对两种线性表的操作函数,我们需要手动将其命名为llist_create()alist_create() ,如果调整部分代码使用的线性表类型,这些相关的函数也要进行修改。

struct 的扩展

为了解决上面提到的问题,我们将C语言中的struct进行扩展。

在C语言中,struct只能在其中使用 成员变量 ,即只能包含 数据 ,而在OOP的思想中,struct还可以包含 成员函数 ,即将 数据操作 绑定到了一起。

这里是C++中的一个链表的定义

struct node {
    elem* val;
    struct node* next;
};

struct linkedlist {
    struct node* llist;

    static linkedlist create();
    int size() const;
    elem* get(int index) const;
    void add(elem val);
    // ...
};

通过这样绑定,我们可以使用list.add(1)而非llist_add(list,1) alist_add(list,1)操作一个线性表。list.add(1)中,add函数的具体操作取决于list在create时的类型,这样我们的程序就能根据数据类型来自动选择操作类型。比如当list是由链表构成的,list.add(), 执行的就是 linkedlist::add:: 前指示其所在,后指示其函数。

对象和类

上面提到的绑定了数据和操作的数据类型,被称作类(Class)

由类创建的一个实例,被称作对象实例。对象包含他的状态和行为,状态即类中变量的具体值,行为即他进行的操作。

类和对象的关系可以类比为人类和烁烁(或是其他具体的人):人类都有如年龄,身高等数据,而烁烁在一个时刻里年龄和身高只有一个具体的值;人类可以进行多样的行为,而烁烁可以进行夜跑这一具体的行为。

访问控制

对于类中的一些数据或者函数,我们有时不希望他被外部代码访问或调用,比如下面这个类:

1
2
3
4
5
6
7
struct User {
    int id, age;
    char* password;

    bool checkPassword(char* pw); // check if pw == password
    // ...
};

我们可以通过printf("%s", user1.password);轻松获取密码,或者通过其他方式修改这些值。

为了避免这些问题,C++提供了 public, privateprotected 三种访问权限,public表示其下的成员可以被外部调用,private表示其下的成员只能在类的成员函数中调用。

修改后的代码如下:

struct User {
private:
    int id, age;
    char* password;
public:
    bool checkPassword(char* pw); // check if pw == password
    void setAge(int v) {
        if (v >= 0)
            age = v;
    }
    int getAge() { return age; }
};

此时,我们就无法从外部查看password的值。同时增加两个函数,用于修改和查看age这个保密需求低的值。

值得注意的是,通过private保护的数据不一定是安全的,他更像是一种带功能的标签,用于避免代码在编写过程中对其中成员的越界使用。

下面这段使用了private进行了保护的类仍然是极其不安全的。

struct User {
private:
    int id, age;
    char name[10];
    char* password;
public:
    bool checkPassword(char* pw); // check if pw == password
    void setname(char* str) {
        strncpy(name, str, sizeof(name));
    }
    void getname(){
        puts(name);
    }
};

在编译器不进行保护的情况下,name和password在内存中是紧挨着的,当我们调用setname时,如果输入了一个长度超过10的str,会导致name的结尾没有 '\0' ,而puts输出时会输出到 '\0' 为止,此时puts(name)就会将password一同输出。

1
现代编译器为了安全考虑会自动将可修改的name存储到不可修改的password后面,避免泄露过多信息。

在C++中,class默认是private的,struct默认是public的,这是其唯一区别。

小结

综上所述,封装 就是将数据和操作数据的函数绑定在一起,并给予必要的访问控制。通过封装的思想构建数据结构,有利于我们对其数据和相关的函数进行操作,即围绕对象进行操作。

继承

假设我们需要写一个画图软件,我们可以使用上面学到的类,每一个图形使用一个类,得到如下代码:

class Point { /* ... */ };  // 用来保存坐标

class Circle {
public:
    Point center;
    int radius;
    void draw() {
        // 做一些准备(例如准备画笔)
        // 画圆!
        // 做一些后续处理(例如重置画笔)
    }
};

class Rectangle {
public:
    Point center;
    int width, height;
    void draw() {
        // 做一些准备(例如准备画笔)
        // 画长方形!
        // 做一些后续处理(例如重置画笔)
    }
};

我们发现两种图形中包含了draw() center 以及准备和重置等相同的工作。这时我们想要提取这些类的共同点,组成一个“类的交集”。然后在创建一个图形的类的时候,使用这个“交集”,从而减少代码中的重复内容,便于维护。

继承 指的就是我们克隆已有的类,再增加或修改部分内容得到一个新的类。被继承的类叫基类/父类,产生的新类叫派生类/子类

对于上面的问题,我们可以定义基类

class Shape {       // 基类 Shape
private:
    void prepare()  { /* 做一些准备(例如准备画笔)*/ } 
    void finalize() { /* 做一些后续处理(例如重置画笔)*/ }
public:
    Point center;   // 共有的成员变量

    void draw() {   // 共有的成员函数
        prepare();
        do_draw();
        finalize();
    }
    virtual void do_draw() = 0; // 要求所有派生类都实现 do_draw()
};

然后我们可以继承出不同图形的类,并针对其图形特征添加内容,以及定义do_draw()

class Circle : public Shape {   // Circle 继承 Shape
public:
    int radius;     // 独有的成员变量

    void do_draw() {
        // 画圆!
    }
};

class Rectangle : public Shape { // Rectangle 继承 Shape
public:
    int width, height; // 独有的成员函数

    void do_draw() {
        // 画长方形!
    }
};

此时,我们可以更加简易地添加新的图形,也可以通过修改基类,一次性给所有的图形绘图添加如笔画粗细、颜色等其他特征。

多态

对于前面提到的 add() 或者是 do_draw() 函数,其具体内容是由调用它的对象决定的,这就是OOP中的多态