本文代码实践基于 JDK-17:
java 17.0.11 2024-04-16 LTS
继续上回《Effective Java:02 对所有对象都通用的方法》的分享,这次学习第 3 章:类和接口。
15 使类和成员的可访问性最小化
区分一个组件设计得好不好,唯一重要的因素在于,它对于外部的其他组件而言,是否隐藏了其内部数据和其他实现细节。
上面这句话说的是代码封装,为什么封装会是组件设计是否优秀的衡量标准?主要是因为解耦(decouple):这些组件可以独立地开发、测试、优化、使用、理解和修改。
- 一个大的项目工程,拆解之后,可以分给不同的团队不同的人员分工协作,也就是并行开发,从而提高整体的开发速度;
- 与此同时,由于各自负责的内容变少了,理解和维护组件的成本也就变少了;
- 封装并不会提升当前系统的性能,但却可以提升系统未来的性能:当监测到哪些组件存在性能瓶颈时,便可以对这些组件进行独立优化,而不会影响整个系统的正确性;
- 独立组件可以提高代码复用率,一个组件不仅可以用于 A 系统,也可以用于 B 系统,从而提升软件的可重用性;
- ……
说到 Java 的封装隐藏,就不得不提访问修饰符:
访问修饰符 | 当前类 | 当前包 | 其他包的子类 | 其他包 |
---|---|---|---|---|
private | 可见 | |||
default | 可见 | 可见 | ||
protected | 可见 | 可见 | 可见 | |
public | 可见 | 可见 | 可见 | 可见 |
从上面的表格来看,Java 语言的设计者们好像已经将所有场景都考虑到了,是一个完备的定义。其实并没有,这里还有一种情况被遗漏了,那就是 Java 包之间的关系。目前的设计是要么全可见,要么全封闭,这是不合理的。A 包可以只给 B 包暴露一部分接口(比如 A1,A3),也可以只给 C 包暴露一部分接口(比如 A2,A3),而不是现在这样将所有接口(A1,A2,A3)全部暴露出去。
于是从 Java 9 开始,引入了 Java 平台模块系统(Java Platform Module System),重新定义了 Java 包之间的依赖关系:
了解了访问控制符之后,接下来就是如何运用了。一条简单而实用的原则是:尽可能地使每个类或者成员不被外界访问。 也就是说,能用 private
就不要使用默认修饰符,能用 protectd
就不要使用 public
。以下是一些优秀的实践:
- 只有当一个包的另一个类需要访问当前某个方法时,才需要将
private
移除,即调整该方法的访问权限为包级私有; - 有时候为了便于测试,我们可能会调高类,接口,或方法的访问权限,但上限是包级私有。也就是说,一般不会仅仅为了测试,将访问权限提升到
protected
或public
,这是因为我们可以将测试代码作为被测试包的一部分; - 除了
public static final XxxType xxx = xxxValue;
(其中的xxxValue
是基本类型或是不可变对象的引用)这种情况外,其他情况下都不允许将公有类的实例域暴露出去(即使用public
修饰符)。像public static final Thing[] VALUE = ……
就是一个反面案例,一旦将这个VALUE
暴露出去,我们就无法保证它不被修改,从而导致不可预知的后果; - ……
16 要在公有类而非公有域中使用访问方法
这一条说起来比较简单:公有类不应该直接暴露数据域。
package java.awt;
import java.awt.geom.Point2D;
import java.beans.Transient;
import java.io.Serial;
/**
* A point representing a location in {@code (x,y)} coordinate space,
* specified in integer precision.
*
* @author Sami Shaio
* @since 1.0
*/
public class Point extends Point2D implements java.io.Serializable {
/**
* The X coordinate of this {@code Point}.
* If no X coordinate is set it will default to 0.
*
* @serial
* @see #getLocation()
* @see #move(int, int)
* @since 1.0
*/
public int x;
/**
* The Y coordinate of this {@code Point}.
* If no Y coordinate is set it will default to 0.
*
* @serial
* @see #getLocation()
* @see #move(int, int)
* @since 1.0
*/
public int y;
// ...
// ...
}
java.awt.Point
中直接暴露 x, y
两个域,是 JDK 中的一个反面案例。当然,我们也不用墨守成规,如果确实需要使用包级私有类或私有的嵌套类来暴露域,那也是可行的:这些域确实描述了该类所提供的抽象。毕竟,与调用方法相比,直接访问这些成员变量的代码有更好的可读性。但需要强调的是,这不应该是一个常规操作。
17 使可变性最小化
当一个类被实例化后,该实例就不能再被修改,我们称其为不可变类。很容易想到的是,当我们将类中需要使用到的数据都通过构造函数进行初始化,并且后续不提供修改这些变量的方法,那整个类基本上就是不可变的。在 JDK 中有很多不可变类的实践,比如基本类型的包装类 Double, Long 等,再比如最常使用的 String 等。
public final class Double extends Number
implements Comparable<Double>, Constable, ConstantDesc {
// ...
// ...
/**
* The value of the Double.
*
* @serial
*/
private final double value;
// ...
// ...
}
不过为了严谨,我们来看看成为不可变类必须满足的条件:
1)不提供任何会修改对象状态的方法,最常见的就是各种 setter
方法;
2)类不能被扩展,也就是不能被子类化;
3)类中所有的域都是 final 的,也就是一旦被赋值就不能修改;
4)声明所有的域都为私有的。这样可以防止客户端获得访问被域引用的可变对象的权限,并防止客户端直接修改这些对象;
5)确保对于任何可变组件的互斥访问。如果类具有指向可变对象的域,则必须确保该类的客户端无法获得指向这些对象的引用。
对于最后一条,如果类中某个域是可变对象,暴露出去后就极有可能被修改,所以需要对其进行处理,比如下面的 getBirthDate
方法就是一种处理方式:
import java.util.Date;
public final class ImmutablePerson {
private final String name;
private final Date birthDate;
public ImmutablePerson(String name, Date birthDate) {
this.name = name;
this.birthDate = new Date(birthDate.getTime());
}
public String getName() {
return name;
}
public Date getBirthDate() {
// 返回一个拷贝,防止外部修改内部的Date对象
return new Date(birthDate.getTime());
}
}
再比如下面的 Complex
类,当进行加减乘除时,不会改变当前对象的值,而是返回一个新的对象实例。
public final class Complex {
private final double re;
private final double im;
public Complex(double re, double im) {
this.re = re;
this.im = im;
}
// 两个复数相加,返回一个新的对象实例
public Complex plus(Complex c) {
return new Complex(re + c.re, im + c.im);
}
// ...
// ...
}
这里之所以使用介词(plus)作为方法名,而不使用动词(比如 add),就是为了强调这个方法不会改变对象的值。这类方法又被称为函数(Functional)的方法,用于区别过程的(procedural)或者命令式的(imperative)方法。命令式编程就是将某个运算过程作用在它们的操作数上,从而改变它的状态。
由于不可变对象一旦初始化后就不会改变,所以它是线程安全的,因而这些对象可以被自由地共享。于是我们可以缓存一些经常使用的对象实例,比如:
public static final Complex ZERO = new Complex(0,0);
public static final Complex ONE = new Complex(1,0);
因为可以自由地共享这些不可变对象,因而这些类完全不需要提供 clone
方法或拷贝构造器,像 String 类还有拷贝构造器完全是因为早期不可变对象没有被大家所理解,才一直保留了下来(为了向后兼容)。
除了上面的方式,其实还有一种方法可以让 Complex
成为不可变类:
public class Complex {
private final double re;
private final double im;
// 私有构造函数
private Complex(double re, double im) {
this.re = re;
this.im = im;
}
// 静态工厂方法代替公有构造函数
public static Complex value0f(double re, double im) {
return new Complex(re,im);
}
// 两个复数相加,返回一个新的对象实例
public Complex plus(Complex c) {
return new Complex(re + c.re, im + c.im);
}
// ...
// ...
}
因为私有构造函数的缘故,类即使不用 final
修饰,也是无法进行子类扩展的。
那不可变类有缺点吗?一个经常被人提及的话题就是性能。但这一点,其实无需过多关注,当系统真有性能问题时,再考虑优化也不晚。当然,如果不可变类对象是大型对象,或者构造这个对象需要耗费较高的成本,那确实需要关注。毕竟,在不可变对象中,每一个不同的值都是一个独立的对象,一旦这种对象很多,就会影响系统性能。所以,如果可以,我们应该总是使一些小的值对象,比如 PhoneNumber
和 Complex
,成为不可变的。
最后,对于某些类而言,其不可变性是不切实际的。如果类不能被做成不可变的,仍然应该尽可能地限制它的可变性。
18 复合优先于继承
对软件设计稍微了解一点的话,基本上都听过这一条:组合(复合)优于继承。这里主要的问题在于,继承破坏了封装性。当我们继承一个超类时,大概率会用到超类中特定的实现细节,一旦超类在后续版本中调整,相关子类就不得不跟着调整。此时,子类是脆弱的。
有时候,在超类中增加新方法也有可能导致子类无法编译。比如子类中已经有了方法 void a();
,但超类开发人员可能不知道,便在超类中增加了方法 String a();
。即便超类中增加的方法与子类方法完全相同,即 void a();
,也可能会出 bug,甚至比编译不通过(编译时)酿成更大的生产事故(运行时)。因为子类方法的覆盖完全是无意的,不易被发现,而其实现可能与超类天差地别。
为了解决上面的问题,一种叫做组合的设计便出现了:
private static class UnmodifiableMap<K,V> implements Map<K,V>, Serializable {
@SuppressWarnings("serial") // Conditionally serializable
final Map<? extends K, ? extends V> m;
UnmodifiableMap(Map<? extends K, ? extends V> m) {
if (m==null)
throw new NullPointerException();
this.m = m;
}
public int size() {return m.size();}
public boolean isEmpty() {return m.isEmpty();}
public boolean containsKey(Object key) {return m.containsKey(key);}
public boolean containsValue(Object val) {return m.containsValue(val);}
public V get(Object key) {return m.get(key);}
public V put(K key, V value) {
throw new UnsupportedOperationException();
}
public V remove(Object key) {
throw new UnsupportedOperationException();
}
// ...
}
如上代码示例所示,UnmodifiableMap
并不继承任何 Map 实现类,而是将某个 Map 实例作为当前类的一个私有域。可以看到,UnmodifiableMap
可以调用 Map
实例中的任何方法,但又不会依赖对方的任何具体实现,从而避免了前面继承中的各种弊端。由于这种类只是将原有类实例进行了简单包装,故又被称为包装类,包装类中调用对应实例的某个方法则被称为转发。
那什么时候需要使用继承呢?只有当 B 和 A 确实存在 is-a
的关系时,类 B 才会扩展类 A。也是说,当你想使用继承时,你就问问自己,每个 B 确实是 A 吗?如果不是或答案不确定,那就不要使用。当我们将 A 作为 B 的一个私有域时,这背后隐藏地逻辑其实是:A本质上不是 B 的一部分,只是它的实现细节而已。
JDK 中一些反面案例包括:
Stack
extendsVector
:但栈不是向量;Properties
extendsHashtable
:但属性列表并不是散列表;- ……
19 要么设计继承并提供文档说明,要么禁止继承
对于包内使用继承,一般来说是安全的,因为这些类不会被外部访问到,即使后续变更,其影响范围也仅波及当前包内的类。而对于普通的具体类,当它们跨越包边界进行继承时,则比较危险。这在上一条中已经讨论过了。如果确实需要跨包进行继承呢?那就提供详细的说明文档。
public abstract class AbstractCollection<E> implements Collection<E> {
// ...
// ...
/**
* {@inheritDoc}
*
* @implSpec
* This implementation iterates over the collection looking for the
* specified element. If it finds the element, it removes the element
* from the collection using the iterator's remove method.
*
* <p>Note that this implementation throws an
* {@code UnsupportedOperationException} if the iterator returned by this
* collection's iterator method does not implement the {@code remove}
* method and this collection contains the specified object.
*
* @throws UnsupportedOperationException {@inheritDoc}
* @throws ClassCastException {@inheritDoc}
* @throws NullPointerException {@inheritDoc}
*/
public boolean remove(Object o) {
Iterator<E> it = iterator();
if (o==null) {
while (it.hasNext()) {
if (it.next()==null) {
it.remove();
return true;
}
}
} else {
while (it.hasNext()) {
if (o.equals(it.next())) {
it.remove();
return true;
}
}
}
return false;
}
// ...
// ...
}
如上面代码示例中的 remove
方法,其 Java Doc 就提供了专门说明,即 @implSpec
注解部分。
@implSpec
标签是在 Java 8 中增加的,在 Java 9 中得到了广泛应用。这个标签应该是默认可用的,但是到 Java 9,Java Doc 工具仍然把它忽略,除非传入命令行参数:-tag "apiNote:a:API Note:"
。
如果总结一下这里文档的具体要求,那就是:针对超类中的相关受保护(或公共)方法(包括构造器,实例方法,我们用 A 表示),需要说明这些方法调用了哪些可覆盖的方法(我们用 B 表示),调用的顺序是什么,如果在子类中覆盖这些被调用的可覆盖方法(即 B),会产生什么后果。
我们经常说,方法注释需要描述做什么,而不是怎么做,但这里却将实现细节都描述出来,显然是与软件中的封装相悖的。没错,这就是使用继承带来的副作用。所以,在设计可重写的方法时,设计人员需要认真思考,以提供出合适的钩子(hook),让子类精准覆盖。这其实是有一定难度的,那有可以借鉴的技巧吗?很遗憾,没有。我们唯一能做的,就是思考并发挥想象力,然后多写一些子类进行反复测试。而前面总结的可访问性最小化的原则在这里依然适用,即若无必要,方法都要设计成私有的。
另外,当确认需要使用继承时,在超类的构造函数、 clone 方法(超类实现了 Cloneable 接口)或是 readObject 方法(超类实现了 Serializable 接口)中都不能调用可覆盖方法,因为这些方法在子类执行时,都有可能出现错乱(比如执行两次等),从而导致不可预知的问题。
最后,如果某个类并不是为了继承而设计的,但可能有人也将其当作超类使用。为了避免继承中出现的各类问题,我们可以将类设计成不可子类化,其具体实现在第 17 条中已经阐述过了。
20 接口优于抽象类
关于这一条,笔者之前专门总结过,即这篇文章:接口 vs 抽象类,故就不再进行更多的讨论。
书中花了不少篇幅介绍骨架类。什么是骨架类?其实就是对接口的实现提供一个默认实现,在设计模式中又被称为模板模式。像 JDK 中的 AbstractList,AbstractCollection,AbstractMap 等,都是骨架类:
public abstract class AbstractList<E> extends AbstractCollection<E> implements List<E> {
/**
* Sole constructor. (For invocation by subclass constructors, typically
* implicit.)
*/
protected AbstractList() {
}
// ...
// ...
}
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
// ...
// ...
}
骨架实现类的美妙之处在于,它们为抽象类提供了实现上的帮助,但又不强加“抽象类被用作类型定义时”所特有的严格限制。
由于骨架类就是为了继承而设计的,所以上一条的相关规则也适用于它。
21 为后代设计接口
这一条比较简单,主要讨论了 Java 8 之后,可以通过在接口中添加缺省方法(default method)来扩展接口的利弊。简单来说就是,即使使用缺省方法,也会有一定的风险。比如,某些类没有对缺省方法进行同步更新,导致与类本身的承诺相违背:
public interface Collection<E> extends Iterable<E> {
//...
//...
default boolean removeIf(Predicate<? super E> filter) {
Objects.requireNonNull(filter);
boolean removed = false;
final Iterator<E> each = iterator();
while (each.hasNext()) {
if (filter.test(each.next())) {
each.remove();
removed = true;
}
}
return removed;
}
//...
//...
}
public class SynchronizedCollection<E> implements Collection<E>, Serializable {
//...
//...
@Override
public boolean remove(final Object object) {
synchronized (lock) {
return decorated().remove(object);
}
}
@Override
public boolean removeAll(final Collection<?> coll) {
synchronized (lock) {
return decorated().removeAll(coll);
}
}
// 未重写 removeIf 方法
//...
//...
}
这个例子说的是,Java 8 为 Collection
接口添加了 removeIf
等方法,但第三方的 apache 类库 commons-collections4
中的 SynchronizedCollection
却没有去实现它,这就导致当有客户端使用 SynchronizedCollection
的实例调用 removeIf
方法时,它调用的还是缺省实现,这显然与 SynchronizedCollection
类的初衷是相悖的。
所以,当需要添加缺省方法时,先问自己一个问题:新增该接口是否会破坏现有的接口实现?
22 接口只用于定义类型
接口用于定义类型的意思是:当类实现某个接口时,该接口可以充当这个类实例的类型。而接口又代表着行为,也就是这个类拥有某种能力。但接口只用于定义类型,则告诉我们,不要用接口来干其他事,比如常量定义:
package java.io;
/**
* Constants written into the Object Serialization Stream.
*
* @author unascribed
* @since JDK 1.1
*/
public interface ObjectStreamConstants {
/**
* Magic number that is written to the stream header.
*/
final static short STREAM_MAGIC = (short)0xaced;
/**
* Version number that is written to the stream header.
*/
final static short STREAM_VERSION = 5;
//...
//...
}
这是因为类在内部使用某些常量纯粹是实现细节,不应该轻易暴露到外部,上面的 ObjectStreamConstants
就是一个反例。如果确实需要使用一些常量:
- 最优:将其放在对应类或接口中,供内部使用;
- 次优:将某些有关系的常量封装一个枚举类;
- 最后:将这些常量封装到一个不可实例化的工具类中,比如下面的
PhysicalConstants
:
public class PhysicalConstants {
private PhysicalConstants(){
}
public static final double AVOGADROS_NUMBER = 6.022_140_857e23;
//...
//...
}
23 类层次优于标签类
什么是标签类?看下面的代码示例:
public class Figure {
enum Shape { RECTANGLE,CIRCLE };
//Tag field - the shape of this figure
final Shape shape;
//These fields are used only if shape is RECTANGLE
double length;
double width;
//This field is used only if shape is CIRCLE
double radius;
//Constructor for circle
Figure(double redius){
shape=Shape.CIRCLE;
this.radius=radius;
}
//Constructor for rectangle
public Figure (double lenght,double width) {
shape=Shape.RECTANGLE;
this.length=lenght;
this.width=width;
}
double area(){
switch (shape){
case RECTANGLE:return length*width;
case CIRCLE:return Math.PI*(radius * radius);
default:throw new AssertionError();
}
}
}
很显然,这个类里面塞了太多的东西,如枚举类,标签域以及 switch
语句等。像 RECTANGLE,CIRCLE 就是标签,它们其实是平级概念,将它们放在一个类里面就会显得非常臃肿,职责划分也不清晰,比如 radius 域只有在 CIRCLE 中才有效,那当类在表示 RECTANGLE 时,它就是多余的。
换句话说,标签类正是对类层次的一种简单仿效 ,并且是一种拙劣的效仿,而我们要做的就是将其重构为类层次风格,即子类化:
abstract class Figure {
abstract double area();
}
class Circle extends Figure {
final double radius;
public Circle(double radius) {
this.radius = radius;
}
double area() {
return Math.PI * (radius * radius);
}
}
class Rectangle extends Figure {
final double length;
final double width;
public Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
double area() {
return length * width;
}
}
划分后,每个类都各司其职,而抽象类中则只有与标签无关的公共行为。由此,原先的 switch
语句也便消失无踪了。
24 静态成员类优于非静态成员类
嵌套类,顾名思义就是嵌套在一个类文件中的类。既然是嵌套,那目的也很明显,就是为它的外围类服务。嵌套类包括下面几种:
1)静态成员类(static member class)
public class OuterClass {
private static int outerStaticVar = 10;
private int outerInstanceVar = 20;
static class StaticNestedClass {
void display() {
System.out.println("Outer static variable: " + outerStaticVar);
// 不能直接访问外围类的实例成员,需要拥有外围类的实例才行
// System.out.println("Outer instance variable: " + outerInstanceVar); // Error
}
}
public static void main(String[] args) {
OuterClass.StaticNestedClass nestedObject = new OuterClass.StaticNestedClass();
nestedObject.display();
}
}
静态成员类与普通类没什么区别,只是正好将其放在了某个外围类的文件之中。但它可以访问外围类的所有成员。一般来说,静态成员类的作用是作为外围类的辅助类,协助其完成相关业务逻辑。下面是 HashMap
中的 Node
类:
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
//...
//...
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
return o instanceof Map.Entry<?, ?> e
&& Objects.equals(key, e.getKey())
&& Objects.equals(value, e.getValue());
}
}
//...
transient Node<K,V>[] table;
//...
}
2)非静态成员类(nonstatic member class)
public class OuterClass {
private int outerInstanceVar = 30;
class InnerClass {
void display() {
System.out.println("Outer instance variable: " + outerInstanceVar);
}
}
public static void main(String[] args) {
OuterClass outerObject = new OuterClass();
OuterClass.InnerClass innerObject = outerObject.new InnerClass();
innerObject.display();
}
}
非静态成员类与静态成员类虽然只差一个 static
修饰符,但二者却有相当大的的差异。非静态成员类实例化后,默认会有一个指向外围类的引用,并且这个引用一旦建立就不会更改。显然,这很容易造成循环依赖,使内存垃圾回收难度增加,笔者在Java 之 finalize 终结一文中也曾到过静态内部类可能会导致内存泄露。
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
//...
//...
final class KeySet extends AbstractSet<K> {
public final int size() { return size; }
public final void clear() { HashMap.this.clear(); }
public final Iterator<K> iterator() { return new KeyIterator(); }
public final boolean contains(Object o) { return containsKey(o); }
//...
//...
}
//...
public Set<K> keySet() {
Set<K> ks = keySet;
if (ks == null) {
ks = new KeySet();
keySet = ks;
}
return ks;
}
//...
}
同样是 HashMap
中的内部类,KeySet
就是一个非静态成员类,它的作用可以理解为一个适配器,利用外围类的原始数据进行数据组装或转换,比如这里的集合视图。
3)匿名类(anonymous class)
interface Greeting {
void greet();
}
public class OuterClass {
public Greeting createAnonymousClass() {
Greeting greeting = new Greeting() {
@Override
public void greet() {
System.out.println("Hello from Anonymous Class");
}
};
return greeting;
}
public static void main(String[] args) {
OuterClass outerObject = new OuterClass();
Greeting greeting = outerObject.createAnonymousClass();
greeting.greet();
}
}
匿名类,就是在使用时才创建的类,即声明时实例化。除此之外,我们无法对其进行实例化,基于此,使用它的时候就有了相关限制:
- 不能用作
instanceof
测试; - 不能实现某个接口,或继承某个类;
- 除了从超类中继承的成员之外,客户端无法使用任何成员;
- 匿名类要简短( 10 行或更少),否则在表达式中会影响阅读体验;
- ……
在 Java 引入 Lambda 之前,匿名类是动态创建小型函数对象和过程对象的最佳方式。
4)局部类(local class)
public class OuterClass {
public void testLocalClass() {
class LocalClass {
void display() {
System.out.println("Hello from Local Class");
}
}
LocalClass localObject = new LocalClass();
localObject.display();
}
public static void main(String[] args) {
OuterClass outerObject = new OuterClass();
outerObject.testLocalClass();
}
}
局部类是使用最少的一种内部类,在任何可以定义局部变量的地方都可以定义局部类,并且遵守相同的作用域规则。局部类同时拥有前面三种内部类的相关特性,由于使用的不多,这里就不再赘述。
简而言之,每一种内部类都有相应的用途,我们要根据应用场景择优选择。当然,能用静态成员类,就不要用非静态成员类。
25 限制源文件为单个顶级类
这一条应该是每个开发人员的共识,所以就不做过多讨论。简单来说就是,不要将多个顶级类放到同一个 Java 文件中,至于这样做的问题,显而易见:没有可读性,没有可维护性,像一团卷在一起的毛线,盘根错节,根本无从下手。
好,类和接口的相关实践就简单总结到这里, 下期再见。