面向对象是什么

近两年设计了几个系统,不管是直接使用传统设计ER图,还是使用4C建模,但在做架构评审时,ER却都是重中之重,让人不得不深思,编程思想经过了一代代发展,为什么还在围绕ER,在远古时代,没有OO,没有DDD,但为什么延续至今的伟大软件也比比皆是

带着这个问题,需要回头看看,结构化编程为什么不行?面向对象因何而起,到底解决了什么问题?

《架构整洁之道》也特别介绍了面向对象编程,面向对象究竟是什么,大多从三大特性:封装、继承、抽象说起,但其实这三种特性并不是面向对象语言特有

结构化编程

提到结构化编程就自然想到其中的顺序结构:代码按照编写的顺序执行,选择结构: if/else,而循环结构: do/while

虽然这些对每个程序员都很熟悉,但其实在结构化编程之间还有非结构化编程,也就是goto语句时代,没有if else、while,一切都通过goto语句对程序控制,它可以让程序跑到任何地方执行,这样当代码规模变大之后,就几乎难以维护

编程是一项难度很大的活动。因为一个程序会包含非常多的细节,远超一个人的认知能力范围,任何一个细微的错误都会导致整个程序出现问题。因此需要将大问题拆分成小问题,逐步递归下去,这样,一个大问题就会被拆解成一系列高级函数的组合,而这些高级函数各自再拆分成一系列低一级函数,一步步拆分下去,每一个函数都需要按照结构化编程方式进行开发,这也是现在常被使用的模块功能分解开发方式

结构化编程中,各模块的依赖关系太强,不能有效隔离开来,一旦需求变动,就会牵一发而动全身,关联的模块由于依赖关系都得变动,那么组织大规模程序就不是它的强项

面向对象

正因为结构化编程的弊端,所以有了面向对象编程,可以更好的组织程序,相对结构局部性思维,我们有了更宏观视角:对象

封装

把一组相关联的数据和函数圈起来,使圈外的代码只能看见部分函数,数据则完全不可见;如类中的公共函数和私有成员变量

提取一下关键字:

  1. 数据,完全不可见
  2. 函数,只能看见
  3. 相关联

这些似乎就是我们追求的高内聚,也是常提的充血模型,如此看,在实践中最基本的封装都没有达成

到处是贫血模型,一个整体却分成两部分:满是大方法的上帝类service与只有getter和setter的model

service对外提供接口,model传输数据,数据库固化数据,哪有封装性,行为与数据割裂了

怎么才能做到一个高内聚的封装特性呢?

设计一个类,先要考虑其对象应该提供哪些行为。然后,我们根据这些行为提供对应的方法,最后才是考虑实现这些方法要有哪些字段

并且对于这些字段尽可能不提供getter 和 setter,尤其是 setter

暴露getter和setter,一是把实现细节暴露出来了;二是把数据当成了设计核心

方法的命名,体现的是你的意图,而不是具体怎么做

1
2
3
4
5
6
7
8
9
// 修改密码 
public void setPassword(final String password) {
this.password = password;
}

// 修改密码
public void changePassword(final String password) {
this.password = password;
}

把setter改成具体的业务方法名,把意图体现出来,将意图与实现分离开来,这是一个优秀设计必须要考虑的问题

构建一个内聚的单元,我们要减少这个单元对外的暴露,也就是定义中的【只能看到的函数】

这句话的第一层含义是减少内部实现细节的暴露,它还有第二层含义,减少对外暴露的接口

最小化接口暴露。也就是,每增加一个接口,你都要找到一个合适的理由。

总结:
基于行为进行封装,不要暴露实现细节,最小化接口暴露

继承

先看继承定义:

继承(英语:inheritance)是面向对象软件技术当中的一个概念。这种技术使得复用以前的代码非常容易,能够大大缩短开发周期,降低开发费用
继承就是子类继承父类的特征和行为,使得子类对象(实例)具有父类的属性和方法,或子类从父类继承方法,使得子类具有父类相同的行为

从定义看,继承就是为了复用,把一些公共代码放到父类,之后在实现子类时,可以少写一些代码,消除重复,代码复用

继承分为两类:实现继承与接口继承

1
2
3
Child object = new Child();

Parent object = new Child();

但有个设计原则:组合优于继承Composition-over-inheritance

为什么不推荐使用继承呢?

继承意味着强耦合,而高内聚低耦合才符合我们的道,但其实并不是说不能使用继承,对于行为需要使用组合,而数据还得使用继承

这样解释似乎不够形象,再进一步讲,继承也违背了《SOLID》中的OCP,继承虽然可以通过子类扩展新的行为,但因为子类可能直接依赖父类实现,导致一个变更可能会影响所有子类。也就是讲继承虽然能Open for extension,但很难做到Closed for modification

借用阿里大牛的示例:

有个游戏,基本规则就是玩家装备武器去攻击怪物

  • 玩家(Player)可以是战士(Fighter)、法师(Mage)、龙骑(Dragoon)
  • 怪物(Monster)可以是兽人(Orc)、精灵(Elf)、龙(Dragon),怪物有血量
  • 武器(Weapon)可以是剑(Sword)、法杖(Staff),武器有攻击力
  • 玩家可以装备一个武器,武器攻击可以是物理类型(0),火(1),冰(2)等,武器类型决定伤害类型
1
2
3
4
5
6
7
8
9
10
11
12
13
public abstract class Player {
Weapon weapon
}
public class Fighter extends Player {}
public class Mage extends Player {}
public class Dragoon extends Player {}

public abstract class Weapon {
int damage;
int damageType; // 0 - physical, 1 - fire, 2 - ice etc.
}
public Sword extends Weapon {}
public Staff extends Weapon {}

攻击规则如下:

  • 兽人对物理攻击伤害减半
  • 精灵对魔法攻击伤害减半
  • 龙对物理和魔法攻击免疫,除非玩家是龙骑,则伤害加倍
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
public class Player {
public void attack(Monster monster) {
monster.receiveDamageBy(weapon, this);
}
}

public class Monster {
public void receiveDamageBy(Weapon weapon, Player player) {
this.health -= weapon.getDamage(); // 基础规则
}
}

public class Orc extends Monster {
@Override
public void receiveDamageBy(Weapon weapon, Player player) {
if (weapon.getDamageType() == 0) {
this.setHealth(this.getHealth() - weapon.getDamage() / 2); // Orc的物理防御规则
} else {
super.receiveDamageBy(weapon, player);
}
}
}

public class Dragon extends Monster {
@Override
public void receiveDamageBy(Weapon weapon, Player player) {
if (player instanceof Dragoon) {
this.setHealth(this.getHealth() - weapon.getDamage() * 2); // 龙骑伤害规则
}
// else no damage, 龙免疫力规则
}
}

如果此时,要增加一个武器类型:狙击枪,能够无视一切防御,此时需要修改

  1. Weapon,扩展狙击枪Gun
  2. Player和所有子类(是否能装备某个武器)
  3. Monster和所有子类(伤害计算逻辑)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Monster {
public void receiveDamageBy(Weapon weapon, Player player) {
this.health -= weapon.getDamage(); // 老的基础规则
if (Weapon instanceof Gun) { // 新的逻辑
this.setHealth(0);
}
}
}

public class Dragon extends Monster {
public void receiveDamageBy(Weapon weapon, Player player) {
if (Weapon instanceof Gun) { // 新的逻辑
super.receiveDamageBy(weapon, player);
}
// 老的逻辑省略
}
}

由此可见,增加一个规则,几乎链路上的所有类都得修改一遍,越往后业务越复杂,每一次业务需求变更基本要重写一次,这也是为什么建议尽量不要违背OCP,最核心的原因就是现有逻辑的变更可能会影响一些原有代码,导致一些无法预见的影响。这个风险只能通过完整的单元测试覆盖来保障,但在实际开发中很难保障UT的覆盖率

也由此可见继承的确不是代码复用的好方式

从设计原则角度看,继承不是好的复用方式;从语言特性看,也不是鼓励的做法。一是像Java,只能单继承,一旦被继承就再也无法被其他继承,而且java中有Variable Hiding的局限性

比如现在添加一个业务规则:

  • 战士只能装备剑
  • 法师只能装备法杖
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Data
public class Fighter extends Player {
private Sword weapon;
}

@Test
public void testEquip() {
Fighter fighter = new Fighter("Hero");

Sword sword = new Sword("Sword", 10);
fighter.setWeapon(sword);

Staff staff = new Staff("Staff", 10);
fighter.setWeapon(staff);

assertThat(fighter.getWeapon()).isInstanceOf(Staff.class); // 错误了
}

其实只是修改了父类的weapon,并没有修改子类的;由此编程语言的强类型无法承载业务规则。

继承并不是复用的唯一方法,如ruby中有mixin机制

多态

多态(Polymorphism)按字面的意思就是“多种状态”。在面向对象语言中,接口的多种不同的实现方式即为多态

在上一讲,接口继承更多是多态特性

只使用封装和继承的编程方式,称之为基于对象编程,而只有把多态加进来,才能称之为面向对象编程,有了多态,才将基于对象与面向对象区分开;有了多态,软件设计才有了更大的弹性

多态虽好,但想要运用多态,需要构建出一个抽象,构建抽象需要找出不同事物的共同点,这也是最有挑战地方。在构建抽象上,接口扮演着重要角色:一接口将变的部分和不变部分隔离开来,接口是约定,约定是不变的,变化的是各自的实现;二接口是一个边界,系统模块间通信重要的就是通信协议,而接口就是通信协议的表达

1
2
3
ArrayList<> list = new ArrayList();

List<> list = new ArrayList();

二者之间的差别就在于变量的类型,是面向一个接口,还是面向一个具体的实现类;看似没什么意义,但在《SOLID》中可以发现,几乎所有原则都需要基于接口编程,才能达到目的

而这也就是多态的威力

就java这门语言,继承与多态相互依存,但对于其他语言并不是如此

总结

除了结构化编程和面向对象编程,现在还有函数式编程,然通过上面的阐述,回到开篇的问题,我应该是把编程语言与编程范式搞混了,像结构化编程、面向对象编程是一种编程范式,而具体的C、Java其实是编程语言,对于编程语言是年轻的,的确在很多伟大软件之后才诞生,但编程范式是一直存在的,面向对象范式并不是java之后才有

更不是C语言不能创造伟大软件,语言不过是工具,而最最重要的是思维方式,最近思考为什么TDD,DDD这些驱动式开发都很难,关键还是思维方式的转变

为什么都要看ER图呢,这里面又常被混淆的概念:数据模型与领域模型,下一篇再分解

Reference

《架构整洁之道》

《软件之美》

朱兴生 wechat
最新文章尽在微信公众号『码农戏码』