Java与面向对象

面向对象三大基本特征

面向对象的三大基本特征分别是封装 (Encapsulation), 继承 (Inheritance) 和多态 (Polymorphism).

面向对象的概念大多过于抽象, 我在下文中将使用一些案例来说明.

封装

封装是什么?

封装, 指的一般是对于对象的封装. 一个对象具有其自身内部的字段 (Field) (在 Java 中也可以称为属性 (Property), ADT 设计的语境下称为Rep (表示, Representation)), 也有可以在外部调用的操作. 一般来说外部可以使用的是由类提供的方法, 而直接操作类的字段是一种不安全的, 甚至可能是破坏性的行为. 所以, 设计类的时候, 应有意避免使用的时候对数据类型造成的破坏. 这样的行为可以称之为封装. 封装是面向对象最基本的要求.

封装的一个示例

在 Java 中, 字段和属性的概念是相同的. 但是在 C#中, 它们是两个不同的概念. 对于一个 Java 字段, 我们需要实现 getter 和 setter 两个方法, 而在 C#中, 属性是对于字段的封装, 而 getter 和 setter 演变为属性的get访问器和set访问器. 举一个不够严谨但是足够通俗的例子, 如果你在你的保险箱中存有钱, 而别人想知道你的保险箱中存储了多少钱, 那么, 你首先可以同意或拒绝说出箱子中的金额. 如果你同意告知, 那么你需要去清点保险箱中的金额并告知询问者.

在这个例子中, 金额是private的, 而你是public的. 当他人问你金额的时候, 应该向你询问, 而不是任由询问者代你清点金额. 保险箱中的钱就像是一个字段, 保险箱就像是private, 而询问者需要问你保险箱的金额, 就像它们需要调用访问器 (或 getter, setter 方法) 一样.

封装的另一个示例

和结构化的程序设计相比, 面向对象的程序设计更注重对象, 而非过程.

下面用翁恺老师的举例来描述一下我对于封装的理解. 在结构化程序设计的语言 (例如 C 语言) 中, 如果需要让小明站起来, 我们需要操作小明的某一块和站立相关的肌肉, 而不是使用小明的大脑, 脊髓, 和神经. 如果需要增强程序的健壮性, 我们需要检查小明是否可以顺利地站起来, 例如站起来的过程中头部会不会碰到房顶, 身体是否有充足的空间站立等等, 而不是用小明的眼睛或其他器官观察和判断. 这些操作非常像是一种程序员越俎代庖的行为.

但是在面向对象的程序设计语言 (例如 C++和 Java) 中, 小明的肌肉是private的, 外界无权直接操作小明的肌肉 (不要考虑外科手术之类的离谱情况!), 但是他的耳朵等感受器官直接暴露给外界, 是public的, 所以如果你希望小明站起来, 直接对他传达 “起立” 的命令, 让小明自行执行这一操作.

在面向对象的思想中, 小明听到有人要他起立, 他首先需要用眼睛和记忆判断他能否顺利站立, 使用大脑分析并判断如何站立, 然后通过脊髓和神经操作肌肉收缩和舒张来起立. 这和结构化的程序设计相比, 更符合人类的思维.

接下来在代码的层面分析一下结构化的程序设计和面向对象程序设计. 如果使用结构化的程序设计, 我们能发现函数之间是高度耦合的. 例如, 仅仅stand()函数就需要至少四组参数, 第一组是需要起立的人自身及状态, 第二组是位置参数, 第三组是环境参数, 第四组是身体状况. 这样的函数设计是高度耦合的, 且由于在结构化的程序设计中, 函数之间没有任何的组织, 这样导致多人协作的难度大幅提升.

但是在面向对象的程序设计中, 就不会出现这样的麻烦. 一个人可以被分成若干的系统, 器官, 组织, 甚至是细胞, 这样一来虽然在类内部的耦合度依然较高, 但是类之间的耦合度得以大大降低. 每一个类都需要将自己的工作做好, 其它的工作和自己关系不大.

封装的实现

封装的实现主要是依靠访问说明符. 访问说明符说明了一个类成员的可见性, 以及类的可见性, 访问策略等等.

字段和方法的常见的访问说明符如下表.

访问说明符含义
public对所有的类都是可见的
protected在包内是可见的, 不同包的子类不能访问其实例的方法
default在包内是可见的
private在类内是可见的

注:

  • privateprotected不能用来修饰类
  • 子类的概念来自于后面提到的 “继承”
  • 对于接口, 缺省的访问说明符是public

为什么说 C++不是一门完全面向对象的语言?

这一切归功于 C++中最强大的功能: 指针. 如果在 C++中, 一个字段被设置为private, 但是你仍然可以通过指针在类的外部修改private字段的值. 但是 Java 等语言有 JVM 来确保private字段不会被意外访问.

继承

接口 (Interface) 和抽象类 (Abstract Class) 的基本概念

抽象类是一种不能被实例化的类. 抽象类可以说是一种防御式的语法, 目的是防止其它协作者错误地将你设计的类实例化, 而在其它方面上特点和一般的类几乎没有什么区别. 抽象类可以有字段和方法等等, 可以实现一个接口, 可以继承自一个类, 当然一定可以被继承. 每一个抽象类的方法都是隐式声明为abstract的方法. 所以不需要在方法前添加abstract说明符.

接口在一定程度上和抽象类相似, 但是除了不能被实例化外, 还不能有非public static final的字段.

接口和抽象类的作用

在面向对象的编程中, 我们需要考虑每一个实体的类型, 以及它们之间的相似性, 再进一步抽象出相关的特征, 能力层面的特征可以抽象为接口, 能力和属性层面都有的特征可以抽象为一个抽象类, 或者具体类 (严格地说, 就是非抽象的类).

继承关系和继承树

解释过了接口和类之后, 再讲继承就简单了很多. 如果我们需要有共性的一些类型, 那么我们需要找出它们在能力上的共同特征, 并抽象为一个接口. 然后视实际情况而定, 声明抽象类.

在 Java 中, 继承可以发生在接口之间, 类之间, 这种继承需要使用extends关键字. 也可以使类继承自接口, 这种继承需要使用implements关键字. 以下是一组继承的例子.

public interface IChirpable {}
public interface ITweetable extends IChirpable {}
public interface IFlyable {}
public interface ISwimmable {}
public abstract class Animal {}
public abstract class Bird extends Animal {}
public abstract class FlyableBird extends Bird implements IFlyable {}
public abstract class UnflyableBird extends Bird {}
public class Sparrow extends FlyableBird implements ITweetable {}
public class Swan extends FlyableBird implements IChirpable {}
public class WildGoose extends FlyableBird implements IChirpable {}
public class Ostrich extends UnflyableBird implements IChirpable {}

而这个继承的关系就构成了一个继承树.

方法重写 (Overriding)

在这一部分我将继续使用上一部分中的鸟的例子来说明. 一般来说, 鸟类的飞行原理相差不大, 所以在FlyableBird中, 可以实现一个基本的Fly方法. 但是大雁在长途飞行中排成 “人” 字, 这样就可以靠空气的力量让多数大雁更省力飞行, 这种情况下, 大雁的飞行原理和其他的会飞的鸟类不同, 所以需要重新实现Fly方法.

假设Fly()是接口IFlyable的一个方法, 那么:

public interface IFlyable {
	void Fly();
	...
}

public abstract class FlyableBird extends Bird implements IFlyable {
	@Override    // This annotation informs the JVM
	public void Fly() {
		/* Implementation A */
	}
}

public class WildGoose extends FlyableBird implements IChirpable {
	@Override
	public void Fly() {
		/* Implementation B */
	}
}

在这个例子中, 我们能发现方法继承的意义. 本质上来说, 就是一个方法的实现大体上相同, 但是有个别子类的方法有差异的情况. 静态类型检查无法判断调用哪一个方法, 但是当一个对象被声明时, JVM 能判断其具体的类型. 所以选择一个正确的方法发生在运行时.

多态

多态的分类

多态有多种存在的形式, 例如方法调用的多态, 泛型的多态, 子类型多态三种

方法的重载 (Overloading)

方法重载, 就是在同一个类中声明多个方法, 每一个同名方法的参数列表必须不同. 方法的重载实际上是为了节约协作成本, 让多种方法共用相同的方法名, 可以便于调用者的使用. 决定使用哪一个方法重载发生于静态类型检查时期. 也就是无论是否使用方法的重载, 都不会影响运行时的状态.

以下是方法重载的一个典型案例:

public int add(int p1, int p2) {
	return p1 + p2;
}

public double add(double p1, double p2) {
	return p1 + p2;
}

方法重载和方法重写

方法的重载和方法的重写不一样, 重载是多个方法有相同的名称, 但是参数列表不同, 返回值可能不同. 但是方法重写时, 参数列表是必须相同的.

一种特殊的情况是如果父类含有一个参数不同的同名方法, 那么这两个方法实际上也构成一种方法的重载. 类似地, 在重写Object类若干基础方法的时候, 一定要注意传入参数类型必须和Object的声明一致. (为了避免这样的情况发生, 推荐使用@Override Annotation)

泛型 (Generic)

泛型, 是允许类, 接口和方法在声明时使用类型作为一个参数的特性. 实际上, 早在学习数据结构的时候, 我们就已经可以开始接触泛型了. 如果我实现了一个整数栈, 如果需要实现浮点数栈, 就需要重写一个. 但是如果我们使用泛型实现一个栈就不必如此, 只需要在构造栈的时候使用一个类型参数声明即可.

子类型多态与 Liskov 可替代原则

Liskov 可替代原则 (Liskov Substitution Principle, 缩写为: LSP) 是对于 OOP 中子类型多态的限制条件. 简言之, Liskov 规则要求子类型有更严格的 RI, 子类型的所有重写方法必须有比父类型更强的 Spec. 展开来说一共有如下几条:

  • 子类型必须有比父类型更强的 RI
  • 子类型重写的方法必须有比父类型更弱的前置条件, 换言之子类型重写方法的参数类型可以是父类型方法参数类型, 也可以是其参数类型的父类型, 这称为 “反协变原则” 或 “逆变原则” (Contravarience Principle). 这种行为只有在理论上是允许的, 实际上 Java 会禁止这样的重写. 这时如果去掉@Override Annotation, Java 又会认为这是一种方法重载, 无法达到其真实目的.
  • 子类型的重写方法必须有比父类型更强的后置条件, 即子类型重写方法的返回值类型可以是父类型方法的返回值类型, 也可以是父类型方法的返回值类型的子类型, 且子类型不能比父类型抛出更多的异常 (这里 “更多的异常” 不是指异常类的数量, 例如如果父类型方法抛出Exception, 子类型重写方法抛出IOExceptionNullPointerException, 子类型重写方法抛出的异常依然是比父类型方法少的).

LSP 在一定程度上不是一个强制性的规则, 而是一个面向可复用性的 ADT 设计规范.## 面向对象三大基本特征


本作品采用知识共享署名4.0国际许可协议进行许可.
搬运自本人GitHub Page, 以下是本文在GitHub Page的中英文原文
原文 (中)
原文 (En)

  • 24
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值