Java 面向对象的特性
面向对象的编程(OOP)具有三种基本特性:封装、继承、多态。这三种特性不是 Java 中特有的,而是面向对象的语言所共有的。
1. 封装 — 为了访问控制
封装(Encapsulation)就是将数据和方法包装进类中并把具体实现隐藏。隐藏实现(implement hiding)的意思是就是访问控制。访问控制将接口与实现分离。对客户端程序员来说,访问控制划分了其使用类库(library)的边界,即指定了哪些能使用哪些不能使用。对类库提供者来说,可以自由地修改内部实现而不用担心影响到客户端代码的可能。
Java 中的访问机制和 Java 的包组织机制紧密相关,所以这里先对包的相关概念作一些阐述。
包是一组类
简单来说,包就是处在同级目录下的一组类,包的集合称为类库。Java 通过目录结构,结合 package 与 import 关键字,形成了一种命名空间(namespace)的管理机制。使用 package 语句,同级目录下的类被组织到同一命名空间下。注意,package 语句必须处于文件中非注释代码的第一行。
// tool/ironware/Wrench.java package tool.ironware; public class Wrench { // ... }
这种命名空间的管理方式可以很好的解决类名冲突的问题,在不同包中定义同名的类是不存在问题的。需要注意的是,包名与目录结构是严格对应的,所以 package 后指定的包名必须对应真实存在的路径。
在需要用到类时,可以使用完整的名称,这种方式可以防止类名冲突,而更方便的做法是使用 import 语句事先导入该类。
// tool/Decoration.java import tool.ironware.Wrench; public class Decoration { public static void main(String[] args) { Wrench w = new Wrench(); // ... } }
为了创建唯一的包名,通常的做法是使用创建者的 Internet 域名的反序作为包结构。如com.cnblogs.home,实际得到的就是在根目录下的路径名 com/cnblogs/home 。还需注意的是,一般编译后的类文件(.class)与源码(.java)处于不同目录,这是很多工程的标准,要保证 JVM 通过 CLASSPATH 能找到类文件(一般 IDE 会自动做这些工作)。
访问权限修饰符
Java 中的访问控制通过访问权限修饰符来实现,各修饰符及其对应的访问权限如表所示:
修饰符 | 访问权限 | 解释 |
---|---|---|
public | 接口访问权限 | 从任何位置都可随意访问 |
protected | 继承访问权限 | 同包中以及子类可访问 |
空(default) | 包访问权限 | 默认情况,同包中可访问 |
private | 无访问权限 | 仅在本类中可访问 |
各修饰符所控制的访问权限按上表的顺序从上到下逐渐减小,每个访问权限修饰符只对其所修饰的对象(属性/方法/类)起作用。下面对四种访问权限作进一步的说明:
-
定义在 public 后的成员是没有限制的完全可用状态,这些成员就是使用类库的程序员需要关心的部分。
-
protected 所控制的权限是为了“继承”考虑的。当我们希望包中某个类的成员可以被包外部定义的子类所继承,但又不想该成员包被外部的其他类所访问到时,就可以使用 protected 修饰该成员。
-
没有修饰符的默认情况下意味着“包访问权限”,即该对象可以被当前包的任意成员访问到。所以在默认情况下,同一包内的成员之间可以相互访问。另外,如果两个 .java 文件处于相同目录下的但没有通过 package 明确指定自己的包名,它们被认定为处于该目录下的默认包中,所以它们为彼此提供了包访问权限。
-
private 修饰的成员只能在其所属类的内部被访问,它被隔离在本类中。所以对实现者来说,可以有最大的自由去随意修改 private 修饰的成员,这不会影响类外部的任何代码调用。同时 private 有很多花式的使用方式。
定义类时只能使用两种访问权限:public 和 包访问权限。如果不希望类被外界访问,可以将全部构造器声明为 private,这样就不能从外部创建该类的对象。下面是一个例子:
// ./src/encapsulation/Service.java package encapsulation; public class Service { private Service() { System.out.println("I am your FATH*R!"); } public static Service access() { return new Service(); } } // ./src/encapsulation/OneService.java package encapsulation; public class OneService { private OneService() {} private static final OneService service = new OneService(); public static OneService access() { System.out.println("I am your only FATH*R"); return service; } } // ./src/AccessToService.java import encapsulation.Service; public class AccessToService { public static void main(String[] args) { // Service s = new Service(); // error: Service() 在 encapsulation.Service 中是 private 访问控制 Service s = Service.access(); OneService s1 = OneService.access(); } }
我们无法直接创建 Service 对象,而只能通过调用 Service 类提供的 access() 方法获取对象。而 OneService 使用的是一种设计模式(design pattern),称为单例模式(singleton),它只允许我们通过 access() 获取同一个 OneService 对象。
需要注意,在一个具有包访问权限的类中定义一个 public 的构造器并不能真的使这个构造器成为 public, 这样也是无法从包外直接创建该类的对象的。
2. 继承 —— 特别的代码复用
继承(Inheritance)是代码复用(Reuse)的一种特殊的方式,实际上更为直接的方式是在新类中创建现有类的对象,这称为组合(Composition)。组合的方式复用了代码的功能,继承的方式复用了代码的形式进而也能复用了其功能,或者通过重写(Override)改变原有功能。Java 中在定义新类时,通过在类主体的大括号前使用关键字 extends 连接基类名称继承该基类。Java 中定义的所有类都隐式地继承了其基本类 Object。通过继承,派生类自动获得了基类中的非 private 的所有字段和方法,若不在同包中,则获得 public 和 protected 权限的所有字段和方法。
基类的初始化
派生类从外面看起来复制了基类的所有接口,很可能还有更多的方法或字段,但实际上,当派生类的对象被创建时,基类的对象也被包含在里面了。为了保证这一点,Java 一般会自动在派生类构造器中调用基类无参构造器,当基类没有无参数的构造器时,则必须显式地调用其构造器。如下例,Anime 类的构造器会自动调用 VisualWork 的无参构造器,而 Manga 的构造器中则需要显式地调用 Anime 的构造器,否则会在编译时就会因为找不到 Manga 类的基类 Anime 的无参构造器而报错。
class VisualWork { VisualWork() { System.out.println("Visualwork constructor"); } } class Anime extends VisualWork { Anime(int i) { System.out.println("Anime constructor" + i); } } public class Manga extends Anime { public Manga(int i) { super(i); System.out.println("Manga constructor"); } public static void main(String[] args) { Manga naruto = new Manga(1); } } //output //Visualwork constructor //Anime constructor1 //Manga constructor
重载 or 重写
在继承基类过后,通常需要考虑对基类方法的改变,这涉及到重载或者重写。两者的不同在于接口改变与否,重载不改变方法的参数列表,而重写会改变。所以在派生类中通过重载的方式改写了基类方法并不会隐藏基类方法。对于重写,它会完全覆盖原基类方法,但是重写只对那些是基类的接口的方法有效,如果试图重写那些非接口方法(如 private 方法),得到的只是与一个与原来的方法无关的新方法,只是名字一样而已。下例展示了重载与重写的上述特点:
class Cleaner { void clean() { useTool(); System.out.println("cleaning something..."); } private void checkBySelf() { System.out.println("It's clean now."); } void check() { this.checkBySelf(); } void useTool() { System.out.print("Using cleaning tool to "); } } class Clothes {} class Washer extends Cleaner { // @Override // error: java: 方法不会覆盖或实现超类型的方法 void clean(Clothes cl) { useTool(); System.out.println("cleaning Clothes..."); } // @Override // error: 方法不会覆盖或实现超类型的方法 void checkBySelf() { System.out.println("The clothes are clean now."); } @Override void useTool() { System.out.print("Using washing powder to"); } } public class Cleanup { public static void main(String[] args) { Washer w = new Washer(); w.clean(); w.clean(new Clothes()); w.check(); } } //output //Using washing powder tocleaning something... //Using washing powder tocleaning Clothes... //It's clean now.
上例中可以看到,派生类中的 clean() 因为改变了参数列表而没有覆盖掉基类的 clean() 方法,如果遵照 Java 的规范在添加 Override 语法糖,Java 会通过报错来否定你认为这是重写的观点。而 check() 方法调用的是基类中的 checkBySelf(),同样,如果添加 @Override ,Java 则会直接报错以阻止这种自欺欺人的行为。
委托
其实还有一种介于组合与继承之间的复用方式,它将一个成员对象放在正在构建的类中(就是组合),同时在新类中公开来自成员对象的所有方法(就像继承),这种方式称为委托(Delegate)。Java 不直接支持委托语法,但这完全没有阻碍我们实现这种方式,而且可以使用某些 IDE 自动生成委托代码(如 IntelliJ IDEA),如下例。
// reuse/Screen.java public class Screen { void display() {} } // reuse/Oscilloscope.java public class Oscilloscope { private String name; private Screen sc = new Screen(); public Oscilloscope(String name) { this.name = name; } public void display() { sc.display(); } public static void main(String[] args) { Oscilloscope osc = new Oscilloscope("NSEA Protector"); osc.display(); } } //output //display
何时适合使用继承
继承是 OOP 的一大重要特性,但这并不表示我们需要随时使用它。继承是一种代码复用的方式,但其名字依然反映了一种关系:派生类是一种基类。当我们只需要使用某个现有类的功能时,组合就完全能满足这种需求了,所以继承时不必要的,随便使用继承反而会引起类之间关系上的误解。那难道继承就没有实际的用武之地了吗?当然不是。使用继承最有力的理由正来自于 OOP 的另一重要特性——多态。这里简而言之就是,可以派生类可以被自动转换为基类,称为向上转型(upcasting),这想想也是很自然的,下面给出一个例子。
class Pen { public void write() { System.out.println("Write using " + this); } static void compose(Pen p) { p.write(); } } public class ChineseBrush extends Pen { public static void main(String[] args) { ChineseBrush weaselHair = new ChineseBrush(); Pen.compose(weaselHair); } } //output //Write using ChineseBrush@1b6d3586
这里的 compose() 方法定义为接收一个 Pen 对象,而在调用时却被传入了一个 ChineseBrush 对象,这种做法被语法支持的原因就是 ChineseBrush 就是一种 Pen。结论是 compose() 可以接受任何 Pen 的派生类。因此可以这样说,需要用到继承的时候,就是需要向上转型的时候。
3. 多态 — 对象决定代码行为
多态(Polymorphism)从操作上来说就是可以把任何派生类当成它的基类来用,这也就是前面提到的向上转型。从现象上来说就是一个从基类类型的对象可以表现出其任一种派生类的状态。
enum Food { BONE, MEAT, CARROT, GRASS; } class Livestock { public void eat(Food food) { System.out.println("Livestock is eating " + food + "..."); } } class Buffalo extends Livestock { @Override public void eat(Food food) { System.out.println("Buffalo is eating " + food + "..."); } } class Dog extends Livestock { @Override public void eat(Food food) { System.out.println("Dog is eating " + food + "..."); } } public class Owner { public void feed(Livestock ls, Food food) { ls.eat(food); } public static void main(String[] args) { Owner james = new Owner(); Dog bend = new Dog(); james.feed(bend, Food.BONE); Buffalo aya = new Buffalo(); james.feed(aya, Food.GRASS); } } //output //Dog is eating BONE... //Buffalo is eating GRASS...
从上例可以看出 feed() 方法定义的接口参数类型为 Livestock,而在 main() 传入的是 Dog 和 Duffalo 类型。这样做的优势在于我们不需要针对每一种派生类编写各自的 feed() 方法,增强了 feed() 的可扩展性。只与基类打交道的方式简化了编程的,也实现了一种解耦。那么为什么可以这样做呢?原因是 Java 采取了后期绑定的机制。
后期绑定
绑定就是确定某方法调用和某方法体之间的连接关系,而后期绑定是相对于前期绑定而言的。前期绑定就是在程序运行前进行绑定,比如 C 语言中编译过程完成了所有绑定,所以发生的方法调用都是前期绑定。上述例子如果采用的前期绑定,编译器只知道 feed() 中有一个 Livestock 引用,而无法得知 ls.eat() 调用的哪一个方法。后期绑定则是在运行时动态地判定对象类型再由此进行绑定,所以又称为动态绑定或运行时绑定。Java 中后期绑定是默认的绑定方式,除了 static 和 final 方法外,任何方法都是后期绑定的。后期绑定的机制将如何动作的权利交给了对象本身,动作的细节取决于对象自己如何实现的。请看下例。
class Animal { public void move() { System.out.println("Animal is moving..."); } } class Bird extends Animal { @Override public void move() { System.out.println("Bird is flying..."); } } class Cat extends Animal { @Override public void move() { System.out.println("Cat is walking..."); } } class Rabbit extends Animal { @Override public void move() { System.out.println("Rabbit is hopping..."); } }
不同的动物有自己不同的移动方式,它们各自的 move() 方法重写了基类 animal 的 move() 方法。
import java.util.Random; public class AnimalMovement { private Random rand = new Random(); public Animal appear() { switch(rand.nextInt(3)) { default: case 0: return new Bird(); case 1: return new Cat(); case 2: return new Rabbit(); } } public Animal[] group(int size) { Animal[] animals = new Animal[size]; for (int i = 0; i < animals.length; i++) animals[i] = appear(); return animals; } public static void main(String[] args) { AnimalMovement am = new AnimalMovement(); for (Animal animal : am.group(7)) animal.move(); } }
以上的 main() 中生成了动物的组群数组,其中的对象是从 Bird/Cat/Rabbit 类随机生成的,直到在运行时才能得知,遍历数组调用的 move() 方法对应的是哪种类型对象的行为,比如这样:
Rabbit is hopping... Rabbit is hopping... Rabbit is hopping... Cat is walking... Cat is walking... Bird is flying... Rabbit is hopping...
向下转型
通过继承得到的派生类和积累的关系可以有两种,一种是单纯的接口替代关系,即只是重写或不改变基类的方法;一种是添加接口的扩展关系,即添加基类可继承方法中不存在的方法(从 extends 关键字也可以看出这样做是被允许的)。两者有各自的优缺点,其中就涉及到向下转型(downcasting)的安全性问题。向上转型是在继承关系结构中向上层移动,向下转型的方向与向上转型相反。向上转型后类型信息会被忽略掉,因此可能存在的扩展接口也就丢失了,能使用的只有基类接口,这也使得向上转型是绝对安全的。而向下转型可以重新恢复类型信息,重新得到可能的扩展接口,这看起来没什么问题。但是,我们不能保证被向下转型的对象本身真正具有转型的条件,即它可能并不拥有相应的扩展接口,这样做可能让对象无法处理发送的消息,这时实际上已经发生了转型错误,就算发送消息到基类接口也不大可能会产生正确类型的行为。所以向下转型时不安全的,需要进行类型检查,以确保被转型的对象确实可以是被希望的那种类型。
好消息是,在 Java 中任何转型都会被自动检查。类型检查在运行时才会进行,如果对象不能成为指定的类型,则会抛出异常 ClassCastException,这种运行时的类型检查称为运行时类型信息(RTTI)。举个例子,我们为上面 Animal 例子中的 Cat 类型加上一个 catchMouse() 的扩展接口:
class Cat extends Animal { @Override public void move() { System.out.println("Cat is walking..."); } public void catchMouse() { System.out.println("Cat is catching a mouse..."); } }
现在我们分别进行正确的和错误的向下转型。
public class AnimalTest { public static void main(String[] args) { Animal tom = new Cat(); // a2.catchMouse(); // error: 找不到符号 Cat tom = (Cat) tom; tom.catchMouse(); Animal calie = new Bird(); Cat c = (Cat) calie; } }
在编译阶段 tom 的类型是 Animal,因此在 Animal 字节码文件中找不到 catchMouse() 方法,前期绑定失败,则编译失败。然后将 tom 向下转型为 Cat 类型,类型检查正确,则可以正常使用 catchMouse() 接口。第二个对象 kalie 开始类型为 Animal, 但实际对象中包含的是 Brid 类型的方法,所以当试图向下转型为 Cat 类型时,程序在运行时会抛出如下异常。
Exception in thread "main" java.lang.ClassCastException: xx.Bird cannot be cast to xx.Cat at xx.AnimalTest.main(AnimalTest.java:xx)
如果不想看到这个异常,可以使用 instanceof 关键字在转型之前判断对象是否可以是希望转换的类型,如下面这样使用。
if (calie instanceof Cat) Cat c = (Cat) calie;
谨慎使用
多态的设计的确很巧妙,但是在实际使用时我们首先要考虑是否真正需要它。多态依靠继承特性,而从之前的介绍也可以知道,继承并不是代码复用的一般方式,它为代码牵制加入了一种层次关系,贸然使用则会带来不必要的复杂性,很多时候选择组合才更为合适,因为它更加灵活。扩展上面的例子,我们在 Livestock 中添加 shout() 方法,并在其派生类中重写该方法。
public class Livestock { public void shout() { System.out.println("~~"); } } class Buffalo extends Livestock { @Override public void shout() { System.out.println("哞~"); } } class Dog extends Livestock { @Override public void shout() { System.out.println("汪~"); } }
接下来我们用组合的方式使 Owner 对象包含一种 Livestock 引用,它被初始化地指向一个 Buffalo 对象。而我们可以在运行时将引用的对象替换为 Dog,由此 letLivestockCry() 就会产生不同的行为。
public class Owner { private Livestock livestock = new Buffalo(); public void changeToDog() { livestock = new Dog(); } public void letLivestockCry() { livestock.shout(); } public static void main(String[] args) { Owner james = new Owner(); james.letLivestockCry(); james.changeToDog(); james.letLivestockCry(); } } //output //哞~~~ //汪~~~
通过这个例子,我们看到了继承和组合各自的作用:通过继承可以表现相同接口的行为变化,通过组合可以进行对象属性的状态变化。例子中 Owner 的属性状态的转变引起了属性行为的变化。
【推荐】博客园的心动:当一群程序员决定开源共建一个真诚相亲平台
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】Flutter适配HarmonyOS 5知识地图,实战解析+高频避坑指南
【推荐】开源 Linux 服务器运维管理面板 1Panel V2 版本正式发布
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 大数据高并发核心场景实战,数据持久化之冷热分离
· 运维排查 | SaltStack 远程命令执行中文乱码问题
· Java线程池详解:高效并发编程的核心利器
· 从“看懂世界”到“改造世界”:AI发展的四个阶段你了解了吗?
· 协程本质是函数加状态机——零基础深入浅出 C++20 协程
· 基于.net6的一款开源的低代码、权限、工作流、动态接口平台
· 一个自认为理想主义者的程序员,写了5年公众号、博客的初衷
· .NET 8 gRPC 实现高效100G大文件断点续传工具
· LinqPad:C#代码测试学习一品神器
· 基于 C# 编写的轻量级工控网关和 SCADA 组态软件