面向对象(OOP)指的是一种基于对象的编程方法论,而不仅仅是方法和函数编程。对象包含数据和方法(也叫行为)。
在面向对象编程(OOP)概念中,我们会学到四种主要的法则——抽象、封装、继承、多态。这四条准则也就是众所周知的面向对象编程范式的四大支柱。
最开始,程序都是使用二进制代码写的,通过控制机械开关来加载程序。后来,硬件的能力得到了提升,计算机专家尝试使用高级语言来简化程序的编写,通过编译器将编写的程序转化为机器指令。
后来随着硬件水平的提升,计算机专家创造了基于小方法的结构化程序设计,这些方法在复用代码、使用局部变量、调试代码和提升代码可维护性方面有很大帮助。
随着计算机计算能力的飞速提升和更复杂应用程序需求的增多,结构化程序设计的瓶颈逐渐显露出来。复杂应用需要和真实世界及现实场景更紧密的建模。
于是,计算机专家研发出面向对象编程。面向对象编程的核心就是我们可以使用类和对象。对象就像真实世界的实体,拥有两个主要特性:
对象是类的实例。每一个对象都有它自己的状态、行为和唯一标识。类是对象的蓝图或模板。对象之间可以通过方法调用进行交流:通常被称为信息传递。
例如,如果我们需要开发一个HR应用,那么这个应用的组成实体或角色就会包括:员工、管理者、部门、工资单、假期、考勤处理等等。为了能使用计算机程序对这些实体进行建模,我们就需要创建类,这些类拥有和真实场景相似的数据属性和行为。
例如,员工实体就可以通过Employee类来表示:
Employee.java(员工类)
public class Employee
{
private long id;
private String title;
private String firstName;
private String middleName;
private String lastName;
private Date dateOfBirth;
private Address mailingAddress;
private Address permanentAddress;
// More such attributes according to application requirements
// Constructor
public Employee(id)
{
this.id = id;
}
// Method
public String getFullName()
{
return String.join(" ", this.firstName,
this.middleName,
this.lastName);
}
public int getAge()
{
//Calculate age from dateOfBirth and return
return ;
}
}
上面的Employee类就如同一个模板,我们可以根据应用程序的需求使用这个类来创建许多不同的员工对象。
创建一个员工对象:
Employee e = new Employee(111);
e.setFirstName("Alex");
..
..
int age = e.getAge();
id字段在获取任意一个员工详细信息或对员工进行排序方面有很大帮助。
对象的标识通常由应用的运行时环境维护,对于Java应用,就是Java虚拟机(JVM)来维护。每次我们创建一个对象,JVM就会给这个对象创建一个哈希码并分配给它。通过这种方法,即使程序员忘记给对象添加id字段,JVM也能保证每个对象都是唯一的。
构造器是一个没有返回值的特殊方法。构造器的名称总是和类的名称相同,但是,构造器可以有参数,通过构造器可以在应用使用该对象之前设置对象的初始状态。
如果我们没有提供任何构造器,JVM会给这个类指定一个默认的构造器,这个默认的构造器没有任何参数。
记住,如果我们给类指定了构造器,那么JVM将不再给这个类分配默认的构造器了。如果需要,我们就需要自己指定默认构造器了。
Employee.java(员工类)
public class Employee
{
// 默认构造器
public Employee()
{
}
// 自定义构造器
public Employee(int id)
{
this.id = id;
}
}
面向对象编程的四个主要特性如下:
当我们将抽象与真实案例联系起来的时候,它将非常容易理解。例如,当我们驾驶一辆汽车时,我们不必关心汽车内部具体的工作原理,我们只需要关心如何通过汽车提供的接口对汽车进行操作即可,例如操纵方向盘、踩刹车、踩油门等等。这里,我们对于汽车的认识就是抽象的。
在计算机科学中,抽象是这样一种过程,数据和程序在形式上被定义为与其意义(语义)相似的表示,同时,隐藏了具体的实现细节。
简单的说,抽象是隐藏与上下文不相关的信息,同时仅展示相关的信息,通过将其与现实世界中的事物进行类比来简化它。
抽象仅抓取与当前视角相关联的那些细节。
典型的抽象可以从以下两个方面来看:
1. 数据抽象
数据抽象是根据多个小数据类型来创建复杂数据类型的方法——这更接近真实世界的实体。例如,一个员工类可以是由多个小对象组成的复杂对象。
public class Employee
{
private Department department;
private Address address;
private Education education;
//So on...
}
因此,如果你想获取员工的相关信息,你从员工对象中就可以获取了,就像你在真实世界中做的那样,问问那个人就可以了。
2. 控制抽象
控制抽象是在一个简单的方法调用中通过隐藏复杂任务的动作序列来实现的,这样使得任务的执行逻辑可以对客户端隐藏,并且将来的修改也不需要修改客户端代码。
public class EmployeeManager
{
public Address getPrefferedAddress(Employee e)
{
//从数据库获取所有的地址
//通过逻辑判断来确定需要展示哪个地址
//返回地址
}
}
在上面这个例子中,如果明天你想修改业务逻辑,使得每次总是优先选择国内的地址,你只需要在 getPrefferedAddress() 方法中修改逻辑即可,客户端是不需要修改的。
在类中包装数据和方法并结合隐藏实现(通过访问控制)通常被叫做封装。封装的结果就是一个拥有属性和行为的数据类型。
无论任何改变,都封装它——一个著名的设计准则
封装本质上包括信息隐藏和实现隐藏。
下面我们通过一个案例来更清晰的理解这个概念。
2.2.1. 信息隐藏
class InformationHiding
{
//限制直接访问内部数据
private ArrayList items = new ArrayList();
//提供一个方法来访问数据 - 方法内部的实现在以后可以安全的修改
public ArrayList getItems(){
return items;
}
}
2.2.2 实现隐藏
interface ImplemenatationHiding {
Integer sumAllItems(ArrayList items);
}
class InformationHiding implements ImplemenatationHiding
{
//限制直接访问数
private ArrayList items = new ArrayList();
//提供一个方法来访问数据 - 方法内部的实现在以后可以安全的修改
public ArrayList getItems(){
return items;
}
public Integer sumAllItems(ArrayList items) {
//在这里你可以按任意顺序做N件事
//这些事你不希望客户端知道
//你可以改变任务序列甚至整个逻辑
//而不会影响客户端
}
}
继承是面向对象编程的另一个重要概念。在Java中,继承是一个类获取父类属性和行为的一个途径。它本质上是在类之间创建了一个父子关系。在Java中,继承主要用于代码复用性以及可维护性。
在Java中,通过关键字 “extends”来继承一个类。“extends”关键字表明我们通过一个已有的类派生出一个新的类。
在Java的术语中,被继承的类叫做父类,继承的类叫做子类。
一个子类从它的父类中继承所有非私有(no-private)成员(包括字段、方法、内部类)。构造器不是成员,因此构造器不会被子类继承,但是构造器可以被子类调用。
2.3.1 继承案例
public class Employee
{
private Department department;
private Address address;
private Education education;
//So on...
}
public class Manager extends Employee {
private List reportees;
}
在上面的代码中,管理员类(Manager)是一种特殊的员工,他可以复用员工类(Employee)的部门、地址、教育程度,并且管理员类又定义了自己的直属下级列表。
2.3.2 继承的类型
单一继承——子类A从一个父类中派生。
class Parent
{
//code
}
class Child extends Parent
{
//code
}
多继承——一个子类可以从多个父类中派生。截至JDK1.7,Java中不允许类的多继承(但允许接口的多继承)。但从JDK1.8往后,借助接口和默认方法,多继承成为可能。
Java中总是支持使用接口实现的多继承。
interface MyInterface1
{
}
interface MyInterface2
{
}
class MyClass implements MyInterface1, MyInterface2
{
}
多层级的继承——指的是一个子类同时又是另一个类的父类的至少三各类的继承。
在下面的案例中,B类是一个父类的同时也是一个子类。
class A
{
}
class B extends A
{
}
class C extends B
{
}
层级继承——指的是一个父类拥有多个继承它的子类。
class A
{
}
class B extends A
{
}
class C extends A
{
}
class D extends A
{
}
混合继承——指的是多个继承类型的结合。当类之间的关系包含两个或多个继承类型时,我们就称之为混合继承。
interface A
{
}
interface B extends A
{
}
class C implements A
{
}
class D extends C impements B
{
}
多态使得我们创建的方法和引用变量拥有在不同的程序上下文中表现出不同的行为的能力。这通常又被叫做一个名字拥有多种形式。
例如,在许多编程语言中,运算符 ‘+’ 被用于将两个数字相加同时也可以连接两个字符串。根据变量的类型,这个运算符会改变自己的行为。这也被称为运算符重载。
在Java中,多态本质上有两种类型:
2.4.1 编译期多态
在编译期多态中,编译器可以在编译时为指定的对象绑定合适的方法,因为编译器在程序编译时就知道了必要的信息来确定调用哪个方法。
这通常又被叫做静态绑定或者早期绑定。
在Java中,这是通过方法重载实现的。在方法重载中,方法可以通过参数的数量、顺序和类型来区分不同的方法。
class PlusOperator
{
int sum(int x, int y) {
return x + y;
}
double sum(double x, double y) {
return x + y;
}
String sum(String s1, String s2) {
return s1.concat(s2);
}
}
2.4.2 运行期多态
在运行期多态中,调用一个覆写的方法是在运行时动态处理的。方法的执行对象是在运行时动态确定的——通常取决于用户驱动的上下文。
这通常被称为动态绑定或者方法覆写。我们也可能听说过它的另一种名字——动态方法调度。
在运行期多态中,通常拥有一个父类和至少一个子类。在类中,我们编写语句来执行一个方法,这个方法存在于父类和子类中。
调用方法的变量的父类类型的变量,实际执行方法的实例的类型是运行时确定的,因为父类类型的变量不仅可以存储自身类型的引用,还可以存储子类类型的引用。
class Animal {
public void sound() {
System.out.println("Some sound");
}
}
class Lion extends Animal {
public void sound() {
System.out.println("Roar");
}
}
class Main
{
public static void main(String[] args)
{
//父类引用指向父类对象
Animal animal = new Animal();
animal.sound(); //Some sound
//父类引用指向子类对象
Animal animal = new Lion();
animal.sound(); //Roar
}
}
除了上面提到的OOP的四条法则,还有一些小的概念帮助我们深刻理解OOP。
在深入理解前,我们最好先理解下模块这一术语。在一般的编程中,一个模块指的是可以执行独立功能的一个类或者一个子应用。在HR应用中,一个类可以执行多个功能,例如发送邮件、生成工资单、计算员工年龄等。
耦合是对模块之间独立性程度的度量。耦合指的是一个软件元素和其他元素的连接有多强。一个好的软件是低耦合的。
这意味着一个类应该执行一个独特的任务或者说这个任务是独立于其他任务的。例如,一个邮箱验证类(EmailValidator)仅用于验证邮箱,同理,一个邮件发送类(EmailSender)只发送邮件。
如果我们将这两个功能包含在一个邮箱工具类(EmailUtils)中,那么这就是一个紧耦合的例子。
内聚性是使模块关联在一起的内部粘合剂。好的软件应该是设计的具有高内聚性。
这意味着一个类或模块应该包含执行它自身功能的所有信息,而不依赖其他的类或模块。例如,一个邮件发送类(EmailSender)应该可以配置SMTP服务器,可以接收发件人的电子邮件、主题和内容。基本而言,它应该仅仅聚焦在发邮件。
应用不应该使用邮件发送类(EmailSender)来执行除发送邮件以外的其他功能。低内聚性将产生庞大的类,这样的类难以维护和理解,并且也会降低可重用性。
关联指的是具有独立生命周期但相互没有所有权的的两个对象。
让我们以老师和学生来举个例子。多个学生可以可以与一个老师关联,一个学生也可以关联多个老师,但是学生和老师之间拥有独立的生命周期。
这两者都可以独立的创建和删除,因而,当一个老师离开学习,我们不必去删除任何学生,同理,当一个学生离开了学校,我们也不必删除任何老师。
聚合指的是具有独生命周期但相互拥有所有权的两个对象。在子类和父类中,子类对象不能归属于其他父对象。
让我们以手机和电池来举个例子。一个电池在一个时刻只能属于一个手机。如果手机停止工作,我们会在数据库中删除它,但电池不会被删除,因为电池还可以工作。因此在聚合中,对象之间拥有所有权,但生命周期是独立的。
组合指的是对象之间没有独立生命周期的关系,如果父类对象被删除了,子类对象也会被删除。
例如,问题和答案之间的关系。一个问题可以拥有多个答案,但是答案不能属于多个问题。如果我们删除了问题,那么它的所有答案也会自动被删除。
组合和继承,两者都可以提高代码可重用性。但是优先选择使用组合而不是继承。
基于继承实现的组合通常会创建多种表示系统必须要执行的行为的接口。接口支持多态行为。实现了指定接口的类被构建并添加到被需要的业务域中。于是,系统行为不通过继承就实现了。
interface Printable {
print();
}
interface Convertible {
print();
}
class HtmlReport implements Printable, Convertible
{
}
class PdfReport implements Printable
{
}
class XmlReport implements Convertible
{
}
这将使代码变得灵活,从而使得系统可以和接口的任何新的实现一起工作。我们应该以接口为变量,作为方法的返回类型或方法的参数类型。
接口充当父类类型。通过这种方法,我们可以在将来创建接口更特殊的实现,而不用修改已有代码。
不要编写重复的代码,一定要使用抽象来抽取某一方面的共性。
作为一条经验法则,如果你在两个地方编写了相同的代码,考虑抽取共性到另一个方法中,并在这两个地方调用这个方法。
所有的软件都会随着时间而变化。因此,封装任何将来你期望或怀疑会改变的代码。
在Java中,使用private方法将实现对客户端隐藏,这样,当你做了修改,客户端不需要被迫修改它的代码。
推荐使用设计模式来实现封装。例如,工厂设计模式封装了创建对象的代码,并且为以后引入新类型而不影响客户端代码提供了灵活性。
这是面向对象类设计的坚定法则之一。它强调一个类有且仅应该有一个职责。
换句话说,我们应该只为一个目的编写、修改、维护一个类。这使得我们在未来可以灵活修改,而不用担心修改对其他实体产生的影响。
该原则强调软件组件应该对扩展开放,对修改关闭。
这意味着我们应该以这样的方法来设计类:无论何时,其他开发者想要修改应用中某个指定条件下的控制流程,他们所需要做的就是扩展我们的类并覆写已有的方法,仅此而已。
如果其他开发者由于我们类的限制而不能设计出想要的行为,我们就应该考虑修改我们的类了。
在整个面向对象编程范式中,还有其他许多概念和定义,我们可以在其他文档中学习到。
欢迎在评论中提出你的问题,学习愉快。