面向对象

语言中的用语并不是共通的,在不同语言中,同一个用语的含义可能会有很大差别。

C++的设计者本贾尼·斯特劳斯特卢普对类和继承给予了正面肯定,然而,“面向对象”这个词的发明者艾伦·凯(Alan kay,他同时也是 Smalltalk 语言的设计者)却持有不同的意见,他对类和继承持否定立场。

img

对于面向对象的理解

我们是怎样理解世界的呢?我们将生活中遇见的事物总结为特定的“物”的概念,它们就是诸如桌子、椅子、银行贷款、公式、人、多项式、三角形、晶体管之类的东西。我们的思考、语言以及行动就是建立在指示、说明和操作这些所谓的“物”的基础之上。我们在用计算机解决问题的时候,有必要将现实世界中的“物”的模型在计算机中建立起来。

img

大部分语言的程序设计中,类并不是不可或缺的,但 Java 语言是例外。Java 语言“把类定义为部件,将其组装起来即是程序设计”。因此,在用 Java 语言编写程序时类是必要的。其他诸如 C++、Python、Ruby 这样的语言,在编写程序时既可以使用类也可以不使用类。

那是使用类好呢,还是不使用也可以呢?

这取决于要编写的程序。如果仅是小规模的程序,没必要使用类的情况居多。也有人认为,在多人分工协作编写的大型程序中,使用类来划分责任范围比较好。图形用户界面的编写中面向对象的特性似乎非常管用。比如设计一个按钮,需要有放置按钮的座标和按钮的宽、高等值,也需要有表达按钮按下时的动作的函数。将实现按钮所必需的这些要素统一到类中,编写程序就会变得简单起来。

归集变量与函数建立模型的方法

除类之外,还有几种其他的方式。

  1. 模块(module)。模块原本是一种将相关联的函数集中到一起的功能。在 Perl 语言中类似的功能被称为包(package)。Perl 语言在引入面向对象时,采用了把用来归集函数的包和用来归集变量的散列(hash)绑定在一起的方法。

  2. 把函数和变量放入散列中。这是 JavaScript 等语言采用的方法。

  3. 闭包(closure)。使用函数执行时的命名空间来归集变量的方法。这种方法主要在函数式语言中使用。

    >为什么把这称为闭包?一个包含了自由变量的开放表达式,它和该自由变量的约束环境组合在一起后,实现了一种封闭的状态。
    >类的存在只不过是因为人们觉得有了它编写程序会更方便些,而约定的一种事项。它并不是什么物理法则或宇宙真理,仅仅是人们的一种约定而已。所以,为了理解为什么会有这样一种约定,我们需要考虑语言设计者的意图。
    

C++ 语言和 Java 语言的类具有以下几个作用:

  • 整合体的生成器

  • 可行操作的功能说明

  • 代码再利用的单位

继承

继承的不同实现策略。继承的实现策略大体可以分为三种。

  • 一般化与专门化

第一种策略是在父类中实现那些一般化的功能,在子类中实现那些专门的个性化的功能。其设计方针就是子类是父类的专门化。

img

  • 共享部分的提取

第二种策略是从多个类中提取出共享部分作为父类。它和一般化与专门化的考虑很不一样。对于子类是否为父类的一种,它的答案是否定的。这种提取出共享部分的设计方针是习惯了函数的一种考虑问题的方法。

img

  • 差异实现

第三种策略认为继承之后仅实现有变更的那些属性会带来效率的提高。它把继承作为实现方式再利用的途径,旨在使编程实现更加轻松。的确有很多这样的情况。但这些情况下通常子类都不是父类的一种。

img

继承的弊端

方法的多样意味着控制的复杂,自由度太高往往会需要我们去限制。

比如说 goto 。

尤其是第三种使用方法——继承已有的类并实现差异部分,这种编程风格会造成多层级的继承树,很容易导致代码理解困难。

这里就提到了编程中很重要的一点,就是可读性。

里氏置换原则

这个原则可以表述为:假设对于 T 类型的对象 x,属性 q(x) 恒为真。如果 S 为 T 的派生类,那么 S 类型的对象 y 的属性 q(y) 也必须恒为真。

这句话换种表达就是,对于类 T 的对象一定成立的条件,对于类 T 的子类 S 的对象也必须成立。

为了保证类的继承关系和类型的父子关系这两种关系之间的一致性,有必要遵守这一原则。这一原则也可以表达为继承必须是 is-a 关系。把子类 S 的所有对象都看作是父类 T 的对象而不会有任何问题,必须要做到这一点。

这一约束条件是非常严格的。当要继承某种类时,需要考虑该类是否可以被继承。假设继承的时候考虑的属性可以使里氏置换原则成立。但是在随后的程序编写过程中,需要的属性可能会越来越多。随着属性的增加,置换原则就有可能被打破。是在设计阶段就把所有属性列出来,只有当置换原则绝对不被打破时才去继承呢?还是在开发阶段如果发现新的属性就放弃类的继承呢?不管哪种方式都很费劲。

多重继承

现实世界中一种事物有可能属于多种分类。为了实现对这种现实情况的模拟,作为工具的程序设计语言是不是应该支持对多个类的继承呢?这就是多重继承的初衷。

多重继承对于实现方式再利用非常便利。

多重继承的问题

多重继承看起来真的很方便。但是,使用多重继承时该如何解决名字解释的问题呢?当问到类中 x 值是什么时,该如何回答呢?

首先,如果这个类本身知道答案,就直接给出回答。其次,如果这个类本身不知道答案,就去问它的父类再给出回答。

但是如下情况就麻烦了。

  • 解决方法 1:禁止多重继承。Java 语言中就禁止了类的多重继承。只要不认可类的多重继承这种方式,就不会有上述问题。这样可以把问题解决得很干脆,只是会以失去多重继承的良好便利性为代价。

    • 委托。取而代之发展起来的概念是委托。这种方法定义了具有待使用实现方式的类的对象,然后根据需要使用该对象来处理。使用继承后,从类型到命名空间都会被一起继承,从而导致问题的发生,这种方法只是停留在使用对象的层面上。

public class TestDelegate {
    public static void main(String[] args){
        new UseInheritance().useHello(); // -> hello!
        new UseDelegate().useHello();    // -> hello!
    }
}

class Hello{   ❶
    public void hello(){
        System.out.println("hello!");
    }
}

class UseInheritance extends Hello {   ❷
    public void useHello(){
        hello();                       ❸
    }
}

class UseDelegate {                    ❹
    Hello h = new Hello();             ❺
    public void useHello(){
        h.hello();                     ❻
    }
}

显示“Hello !”的方法 hello 为类 Hello 所持有(❶)。类 UseInheritance 通过继承类 Hello 自身也持有了方法 hello(❷)并加以使用(❸)。与之不同,类 UseDelegate 并没有继承类 Hello(❹),而是通过句❺持有了类 Hello 的对象。当有需要使用时通过句❻将需要的处理委托给该对象操作。

与从多个类中继承实现强耦合的方式相比,使用委托进行耦合的方式显然要更好一些。对于委托的使用,也不需要在源代码中写死,而是可以通过配置文件在合适的时候注入运行时中去。这个想法催生了依赖注入(Dependency Injection)的概念。

* 接口。刚刚提到 Java 语言中禁止了多重继承,但它也具备实现多重继承的功能。这就需要借助接口(interface)。Java 语言中类的继承用 extends,接口的继承用 implements 来区别表示。另外接口的继承也称为实现。接口是没有实现方式的类。它的功能仅仅在于说明继承了该接口的类必须持有某某名字的方法。多重继承中发生的问题是多种实现方式相冲突时选取哪个的问题。而在接口的多重继承中,尽管有多个持有某某方法的信息存在,但这仅仅表明持有某某方法,不会造成任何困扰。
public class TestMultiImpl implements Foo, Bar {
    public void hello(){
        System.out.println("hello!");
    }
}

interface Foo {
    public void hello();
}

interface Bar {
    public void hello();
}

类 TestMultiImpl 继承了 Foo 和 Bar 两个接口。如果这个类中不实现 public void hello (),编译时将出现“没有实现应该实现的方法”这样的错误。也就是说,继承了接口 Foo 后,这个类就作为一种类型表现出必须持有 public void hello () 的特点,可以让编译器对它进行类型检查。

Java 语言为了仅实现功能上的多重继承引入了接口。PHP 语言和 Java 语言一样不认可多重继承,并从 2004 年发布的 PHP5 开始引入了接口的概念。

  • 解决方法 2:按顺序进行搜索

曾经也有些语言试图通过明确定义搜索顺序来解决冲突问题。

出现过深度优先搜索法,重载和菱形继承时会很麻烦。以及广度优先。

以及后来的C3线性化。

* 父类不比子类先被检查
* 如果是从多个类中继承下来则优先检查先书写的类

  • 解决方法 3:混入式处理

原本,问题是指从一个类到它的祖先类有多种追溯方法。定义仅包含所需功能的类并把它与需要添加这些功能的更大的类糅合在一起。把这种设计方针、混入式处理方式和用来混入的小的类统称为混入处理(Mix-in)。

通常这些小型的类最小限度地定义了一些方法,起到了作为代码再利用单位的作用。为了表明这一点,Python 语言会在该类的名字中加上 MixIn 来标识。

Ruby 语言采用的规则是:类是单一继承的而模块则可以任意数量地做混入式处理。模块无法创建实例,但可以像类一样拥有成员变量和方法。也就是说,模块实质上是从类中去除了实例创建功能。即使类的多重继承被禁止了,通过使用模块的 Mix-In 方式照样可以实现对实现方式的再利用。

  • 解决方法 4:Trait

类具有两种截然相反的作用。一种是用于创建实例的作用,它要求类是全面的、包含所有必需的内容的、大的类。另一种是作为再利用单元的作用,它要求类是按功能分的、没有多余内容的、小的类。当类用于创建实例时,作为再利用单元来说就显得太大了。既然如此,如果把再利用单元的作用特别化,设定一些更小的结构(特性=方法的组合)是不是可以呢?这就是 Trait 的初衷。

已有的 Trait,通过改写某些方法定义新的 Trait 实现继承。还可以通过组合多个 Trait 实现新的 Trait。这就是 Trait 的概要说明。它一方面把问题妥当地分而治之,一方面又因为功能繁多令人困惑。读者们想必都还记得 goto 语句就是因为其功能过于强大而退出历史的舞台的吧。所以说力量过于强大未必是件好事。

Trait 技术是一个很好的开端。它认为类同时具有的作为再利用单元和实例生成器的两种作用是相反的。或许类这一概念作为面向对象的根基具有不可动摇的地位。然而这一概念本身也是从一个雏形慢慢发展得越来越复杂,进一步整理之后再逐渐让渡出某些功能的。现在备受关注的 Trait 和一些其他概念也必将不断地演变下去。经过长时间琢磨沉淀,一部分将臻于成熟被推广使用,最后将变成现在的静态作用域和 while 语句那样被认为是理所当然的存在。


lart
126 声望6 粉丝

生活就是肩膀痛和折腾