当我们说面向XX编程时,我们实际在说什么?

1,149 阅读9分钟

面试官:「谈谈面向对象的特性」

码农:「封装」、「继承」和「多态」

面试官:能具体说一下吗?

码农:「封装」隐藏了某一方法的具体运行步骤,取而代之的是通过消息传递机制发送消息。「继承」即子类继承父类,子类比原本的类(称为父类)要更加具体化。这意味着我们只需要将相同的代码写一次。而「多态」可以使同一类型的对象对同一消息会做出不同的响应。

上面是一个普通的面试场景。这么回答是否正确呢?

你有没有想过何谓「特性」?

「特性」指某事物所特有的性质

那么问题来了,「封装」、「继承」和「多态」是面向对象所特有的吗

  • Java是面向对象语言
  • C是面向过程语言
  • Go是面向类型的语言
  • Clojure是函数式语言

这四种范式的语言都支持「封装」、「继承」和「多态」吗?

我们通过例子来验证四种不同范式的语言是否能实现「封装」、「继承」和「多态」!

封装

先来看Java:

  • Java是通过类来进行封装,将相关的方法和属性封装到了同一个类中

  • 通过访问权限控制符来控制访问权限。

    public class Person { private String name;

    public String say(String someThing) { ... } }

而C则是:

  • 通过方法来进行封装,将具体的过程封装到一个个方法中

  • 通过头文件来隐藏具体的细节

    struct Person;

    void say(struct Person *p);

相对于Java来说,C的封装性实际更好!因为Person里的结构都被隐藏了!

对Go语言来说,乍看之下像是以函数进行封装的,但是实际上在Go语言中函数也是一种类型。所以可以说Go语言是以类型来进行封装的。

func say(){
 fmt.Println("Hello")
}

func main() {
 a := say
 a()
}

而Clojure则主要以函数的形式进行封装。

(defn say []
 (println "Hello"))

可以看出来,四种语言都支持封装,只是封装的方式不同而已

继承

再来看继承,继承实际就是代码复用

继承本身是与类或命名空间无关的独立功能。只不过面向对象语言将继承绑定到了类层面上,而面向对象语言是比较普遍的语言,一直强调继承,所以当我们说继承的时候,默认就是在说基于类的继承。

Java是基于类的继承。也就是说子类可以复用父类定义的非私有属性和方法。

class Man extends Person {
 ...
}

C语言可以通过指针来复用。和下面Go语言比较类似,Go语言相对更简单。而C则更像是奇技淫巧!

struct Person {
 char* name;
}

struct Man {
 char* name;
 int age;
}

struct Man* m = malloc(sizeof(struct Man));
m->name = "Man";
m->age = 20;
struct Person* p = (struct Person*) m; // Man可以转换为Person

而Go语言则是通过匿名字段来实现继承。即一个类型可以通过匿名字段复用另一个类型的字段或函数。

type Person struct {
 name string
}

type Man struct {
 ...
 Person // 引入Person内的字段
}
  • Man通过直接在定义中引入Person,就可以复用Person中的字段
  • 在Man中,既可以通过this.Person.name来访问name字段,也可以直接通过this.name来访问
  • 如果Man中也有name这个字段,则通过this.name访问的则是Man的name,Person里的name被覆盖了
  • 此方案对函数也适用

对于Clojure来说,则是通过高阶函数来实现代码的复用。只需要将需要复用的函数作为参数传递给另一个函数即可。

; 复用的打印函数
(defn say [v]
 (println "This is " v))
 
; 打印This is Man
(defn man [s]
 (s "Man"))

; 打印This is Women
(defn women [s]
 (s "Women")) 

同时Clojure可以基于Ad-hoc来实现继承,这是基于symbol或keyword的继承,适用范围比基于类的继承广泛。

(derive ::man ::person)
(isa? ::man ::person) ;; true

可以看出,四种语言也都能实现继承

多态

多态实际是对行为的动态选择

Java通过父类引用指向子类对象来实现多态。

Person p = new Man();
p.say();
p = new Woman();
p.say();

C语言的多态则是由函数指针来实现的。

struct Person{
 void (* say)( void ); //指向参数为空、返回值为空的函数的指针
}

void man_say( void ){
 printf("Hello Man\n");
}
 
void woman_say( void ){
 printf("Hello Woman\n");
}
...
p->say = man_say;
p.say(); // Hello Man
p->say = woman_say;
p.say(); // Hello Woman

Go语言通过interface来实现多态。这里的interface和Java里的interface不是一个概念

; 定义interface
type Person interface {
 say()
}

type Man struct {}
type Women struct {}

func (this Man) say() {
 fmt.Println("Man")
}

func (this Women) area() {
 fmt.Println("Women")
}

func main() {
 m := Man{}
 w := Women{}
 exec(m) // Man say
 exec(w) // Women say
}

func exec(a Person) {
 a.say()
}
  • Man和Women并没有像在Java里一样实现了interface,而是定义了和在Person里相同的方法
  • exec函数接收参数为interface

Clojure除了可以通过高阶函数来实现多态(上面的例子就是高阶函数的例子)。还可以通过「多重方法」来实现多态。

(defmulti say (fn [t] t))

(defmethod run
 :Man
 [t]
 (println "Man"))

(defmethod run
 :Women
 [t]
 (println "Women"))

(rsay :Man) ; 打印Man,结合Ad-hoc,可以实现类似Java的多态

四种语言同样都能实现多态

问题的解决

从上面的对比可知,「封装」、「继承」和「多态」并不是面向对象所特有的

那么当我们说「面向XX编程时,我们实际在说什么呢」?

我们从解决问题的方式来回答这个问题!

对于一些很简单的问题,我们一般可以直接得到解决方案。例如:1+1等于几?

当我们说面向XX编程时,我们实际在说什么?

但是对于比较复杂的问题,我们不能直接得到解决方案。例如:鸡兔同笼问题,有若干只鸡兔同在一个笼子里,从上面数,有35个头,从下面数,有94只脚。问笼中各有多少只鸡和兔?

对于这类问题,我们的一般做法就是先对问题进行抽象建模,然后再针对抽象来寻找解决方案。

当我们说面向XX编程时,我们实际在说什么?

对应到软件开发来说,对于真实环境的问题,我们先通过编程技术对其抽象建模,然后再解决这些抽象问题,继而解决实际的问题。这里的抽象方式就是:「封装」、「继承」、「多态」!而无论是基于类的实现、还是基于类型的实现、还是基于函数或方法的,都是抽象的具体实现。

当我们说面向XX编程时,我们实际在说什么?

现在再回到问题:当我们说「面向XX编程时,我们实际在说什么呢」?

实际就是,使用不同的抽象方式,来解决问题

抽象方式:只是不同,没有优劣

无论是面向对象编程还是函数式编程亦或面向过程编程,只是抽象方式的差异,而抽象方式的不同导致了解决问题方式的差异。

  • 面向对象将现实抽象为一个个的对象,以及对象间的通信来解决问题。
  • 函数式编程将现实抽象为有限的数据结构,以及一个个的对这些数据结构进行操作的函数,通过函数对数据结构的操作,以及函数间的调用来解决问题。
  • 面向过程编程将现实抽象为一个个数据结构和过程方法,通过方法组合调用以及对数据结构的操作来解决问题。

每种抽象方式都既有优点也有缺点。没有完美的抽象方法

例如,对于面向对象来说,可以很方便的自定义类,也就是增加类型,但是很难在不修改已定义代码的前提下,为既有的具体类实现一套既有的抽象方法(称为表达式问题)。而相对的,函数式编程可以很方便的增加操作(也就是函数),但是很难增加一个适应各种既有操作的类型。

举个例子,在Java里,String这个类在1.6之前是没有isEmpty这个方法的,如果我们想判断一个字符串是否为空,我们只能使用工具类或者就是等着官方提供,而理想的方法应该是“abc”.isEmpty()。虽然现代化的语言都提供了各种手段来解决这个问题,像Ruby这类动态语言可以通过猴子补丁来实现;Scala可以通过隐式转换实现;Kotlin可以通过intern方法实现。但这本身是面向对象这种抽象方式所需要面对的问题。

对于函数式语言来说,比如上面提到的Clojure,它如果要新增一个类似的函数,直接编写一个对应的函数就可以了,因为它的数据结构都实现了统一的接口:Collection,Sequence,Associative,Indexed,Stack,Set,Sorted等。这使得一组函数可以应用到Set,List,Vector,Map。但是相应的,如果你要增加一个数据结构,那就需要实现上面所有的接口,难度可想而知了。

抽象程度与维护成本正相关

面向对象相较于其它抽象方式的优势可能就是粒度相对较大,相对的较易理解

这就像组装电脑一样:

  • 面向对象就像是将电脑拆分成了主板、CPU、显卡、机箱等,你稍微学一学就可以组装了。
  • 而函数式编程就像将电脑拆成了一个个的元器件。你既需要学习相关知识,还得将这些元器件组装起来。难度可想而知了。但是组合方式和自由度则比面向对象好得多。

抽象度越高,也就越难理解。但是抽象度越高,适应性就越强,代码相对就越简洁

抽象程度越高,相应的抽象粒度就更细。抽象粒度越细,灵活性就更好,但也导致了维护难度越大。

总结

编程在思想!编程范式只是辅助你思考的工具!不要被编程范式所限制!你需要考虑的是「我该如何使用XX编程范式实现XXX?」,而不是「XX编程范式能实现什么?」

每种抽象方式都有各自的优缺点。为了取长补短,每种编程范式都有自己的最佳实践。这些最佳实践被收集整理,成为了套路或模式。

例如面向对象里的:

  • 复用代码优先考虑组合而不是继承
  • 为多态而继承
  • 设计原则
  • 23种设计模式
  • ...

参考资料