再谈面向对象的三大特性

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/absurd/article/details/53585009

面向对象的三大特性:封装、继承和多态。这是任何一本面向对象设计的书里都会介绍的,但鲜有讲清楚的,新手看了之后除了记住几个概念外,并没真正了解他们的意义。前几天在youtube上看了Bob大叔讲解的SOLID原则,其中有一段提到面向对象的三大特性,收获很多,但是我并不完全赞同他的观点,这里谈谈我的想法:

封装

『封装』第一层含义是信息隐藏。这是教科书里都会讲解的,把类或模块的实现细节隐藏起来,对外只提供最小的接口,也就是所谓的『最小知识原则』。有个共识,正常的程序员能理解的代码在一万行左右。这是指在理解代码的实现细节的情况下,正常的程序员能理解的代码的规模。比如一个文件系统,FAT、NTFS、EXT4和YAFFS2等,它们的实现是比较复杂的,少则几千行代码,多则几万行,要理解它们的内部实现是很困难的,但是如果屏蔽它们的内部实现细节,只是要了解它们对外的接口,那就非常容易了。

关于『封装』的这一层含义,Bob大叔提出了惊人的见解:『封装』不是面向对象的特性,面向过程的C语言比面向对象的C++/Java在『封装』方面做得更好!证据也是很充分:C语言把函数的分为内部函数和外部函数两类。内部函数用static修饰,放在C文件中,外部函数放在头文件中。你完全不知道内部函数的存在,即使知道也没法调用。而像在C++/Java中,通过public/protected/private/friend等关键字,把函数或属性分成不同的等级,这把内部的细节暴露给使用者了,使用者甚至可以绕过编译器的限制去调用私有函数。所以在信息隐藏方面,『封装』不但不是面向对象的特性,而且面向对象减弱了『封装』。

『封装』的第二层含义是把数据和行为封装在一起。我觉得这才是面向对象中的『封装』的意义所在,而一般的教科书里并没提及或强调。面向过程的编程中,数据和行为是分离的,面向对象的编程则是把它们看成一个有机的整体。所以,从这一层含义来看,『封装』确实是面向对象的『特性』。

面向对象是一种思维方式,而不是表现形式。在C语言中,可以实现面向对象的编程,事实上,几乎所有C语言开发的大型项目,都是采用了面向对象的思想开发的。把C语言说成面向过程的语言是不公平的,是不是面向对象的编程主要是看指导思想,而不是编程语言。你用C++/Java可以写面向过程的代码,也可以用C语言写面向对象的代码。

继承

类就是分类的标准,也就是一类事物,一类具有相同属性和行为对象的抽象。比如动物就是一个类,它描述了所有具有动物这个属性的事物的集合。狗也是一个类,它具有动物所有的特性,我们说狗这个类继承了动物这个类,动物是狗的父类,狗是动物的子类。在C语言中也可以模拟继承的效果,比如:

struct Animal {
...
};
struct Dog {
    struct Animal animal;
    ...
}
struct Cat {
    struct Animal animal;
    ...
}

因为C语言也可以实现『继承』,所以Bob大叔认为『继承』也不算不上是面向对象的『特性』。但是我觉得,C语言中实现『继承』的方式,需要用面向对象的思维来思考才能理解,否则纯粹从数据结构的方式来看上面的例子,理解起来就会大相径庭:animal是Dog的一个成员,所以Animal可以看成是Dog的一部分!Is a 变成了has a。只有在面向对象的思想中,说『继承』才有意义,所以说『继承』是面向对象的『特性』并不牵强。

在C语言里实现多重继承更是非常麻烦了,记得glib里实现了接口的多重继承,但是用起来还是挺别扭的,对新手来说更是难以理解。多重继承在某些情况下,会对编译器造成歧义,比菱形继承结构:A是基类,B和C是它的两个子类,D从B和C中继承过来,如果B和C都重载了A的一个函数,编译器此时就没法区分用B的还是C的了(当然这是可以解决的)。

像Bob大叔说的,Java没有实现多重继承,并不是多重继承没有用。而是为了简化编译器的实现,C#没有实现多重继承,则是因为Java没有实现多重继承:)

除了接口多重继承是必不可少的,类的多重继承在现实中也是很常见的。比如:狼和狗都是狗科动物的子类,猫和老虎都是猫科动物的子类。狗科动物和猫科动物都是动物的子类。但是猫和狗都是家畜,老虎和狼都是野生动物。猫不但要继承猫科动物的特性,还继承家畜的特性。类就是分类的标准,而混用不同的分类标准是多重继承的主要来源。多重继承可以用其他方式实现,比如traits和mixin。

不管是普通继承,接口继承,还是多重继承,在面向对象的编程语言中,实现起来要更加容易和直观,在面向过程的语言中,虽然可以实现,但是比较丑陋,而且本质是面向对象的思考方式。所以『继承』应该称得上是面向对象的『特性』了。介于继承带来的复杂性,现代面向对象的设计中,都推荐用组合来代替继承实现重用。

多态

『多态』本来是面向对象思想中最重要的性质(当然也算不上是特有的性质),但是教科书里都只是介绍了『多态』的表现形式,而没有介绍它用途和价值。『多态』一般表现为两种形式:

  • 允许不同输入参数的同名函数存在。这个性质会带来一定的便利,特别是对于构造函数和操作符的重载。但这种『多态』是在编译时就确定了的,所以只能算成一种语法糖,并没有什么特别的意义。

  • 子类可以重载父类中函数原型完全相同的同名函数。如果只看它的表现形式,在父类中存在的函数,在不同的子类中可以被重新实现,这看起来是吃饱了撑着。但是这种『多态』却是软件架构的基础,几乎所有的设计模式和方法都依赖这种特性。

隔离变化是软件架构设计的基本目标之一,接口正是隔离变化最重要的手段。我们经常说分离接口与实现,针对接口编程,主要是因为接口可以隔离变化。如果没有第二种『多态』,就没有真正意义上的接口。面向对象中的接口,不仅是指模块对外提供的一组函数,而且特指在运行时才绑定具体实现的一组函数,在编译时根本不知道这组函数是谁提供的。我们先把接口简单的理解为,在基类中定义一组函数,但是基类并没有实现它们,而在具体的子类中去实现。这不就是『多态』的第二种表现形式么。

接口怎么能够隔离变化呢?Bob大叔举了一个非常好的例子:

#include <stdio.h>

int main() {
    int c;

    while((c = getchar()) != EOF) {
        putchar(c);
    }

    return 0;
}

这个程序和Hello world是一个级别的,你从键盘输入一个字符,它就显示一个字符。但是它却蕴含了『多态』最精妙的招式。比如说输入吧,getchar是从标准输入(STDIN)读入一个字符,键盘输入是缺省的标准输入,但是键盘输入只是众多标准输入(STDIN)中的一种。你可以从任何一个IO设备读取数据:从网络、文件、内存和串口等等,换成任何一种输入,这个程序都不需要任何改变。

具体实现变了,调用者不需要修改代码,而且它根本不用重新编译,甚至不用重启应用程序。这就是接口的威力,也是『多态』的功劳。

上面的程序是如何做到的呢?IO设备的驱动是一套接口,它定义了打开、关闭、读和写等操作。对实现者来说,不管数据从哪里来,要到哪里去,只要实现接口中定义的函数即可。对使用者来说,完全不同关心它具体的实现方式。

『多态』不但是隔离变化的基础,也是代码重用的基础。公共函数的重用是有价值的,在面向过程的开发中也很容易做到这种重用。但现实中的重用没那么简单,就连一些大师也感叹重用太难。比如,你可能需要A这个类,你把它拿过来时,发现它有依赖B这个类,B这个类有依赖C这个类,搞到最后发现,它还依赖一个看似完全不相关的类,重用的念头只好打住。如果你觉得夸张了,你可以尝试从一个数据库(如sqlite)中,把它的B+树代码拿出来用一下。

在『多态』的帮助下,情况就会大不相同了。A这个类依赖于B这个类,我们可以把B定义成一个接口,让使用A这个类的使用者传入进来,也就是所谓的依赖注入。如果你想重用A这个类,你可以为它定制一个B接口的实现。比如,我最近在一个只有8K内存的硬件上,为一块norflash写了一个简单的文件系统(且看作是A类),如果我直接去调用norflash的API(且看作是B类),就会让文件系统(A类)与norflash的API(B类)紧密耦合到一起,这就会让文件系统的重用性大打折扣。

我的做法是定义了一个块设备的接口(即B接口):

typedef unsigned short block_num_t;

struct _block_dev_t;
typedef struct _block_dev_t block_dev_t;

typedef block_num_t (*block_dev_get_block_nr_t)(block_dev_t* dev);
typedef bool_t (*block_dev_read_block_t)(block_dev_t* dev, block_num_t block_num, void* buff);
typedef bool_t (*block_dev_write_block_t)(block_dev_t* dev, block_num_t block_num, const void* buff);
typedef void   (*block_dev_destroy_t)(block_dev_t* dev);

struct _block_dev_t {
    block_dev_get_block_nr_t   get_block_nr;
    block_dev_write_block_t    write_block;
    block_dev_read_block_t     read_block;
    block_dev_destroy_t        destroy;
};

在初始化文件系统时,把块设备注入进来:

bool_t sfs_init(sfs_t* fs, block_dev_t* dev);

这样,文件系统只与块设备接口交互,不需要关心实现是norflash、nandflash、内存还是磁盘。而且带来几个附加好处:

  • 可以在PC上做文件系统的单元测试。在PC上,用内存模拟一个块设备,文件系统可以正常工作了。

  • 可以通过装饰模式为块设备添加磨损均衡算法和坏块管理算法。这些算法和文件系统都可以独立重用。

『多态』让真正的重用成为可能,没有『多态』就没有各种框架。在C语言中,多态是通过函数指针实现的,而在C++中是通过虚函数,在Java中有专门的接口,在JS这种动态语言中,每个函数是多态的。『多态』虽然不是面向对象的『特有的』属性,但是面向对象的编程语言让『多态』更加简单和安全。

展开阅读全文

没有更多推荐了,返回首页