继承
1. 类、超类、子类
"类"(Class)、"超类"(Superclass)和"子类"(Subclass)是面向对象编程中的重要概念。以下是关于这些术语的简要解释:
- 类(Class):在 Java 中,类是对象的蓝图或原型。你可以将类想象成一个模板,它描述了如何创建某种类型的对象。类定义了对象的属性(也称为数据成员)和方法(也称为函数或行为)。每个对象是类的一个实例。
- 超类(Superclass):超类是一个类从中继承属性和方法的类。也就是说,如果有一个类 B 继承自类 A,那么类 A 就是类 B 的超类。超类有时也被称为父类。
- 子类(Subclass):子类是一个继承自其他类的类。也就是说,如果有一个类 B 继承自类 A,那么类 B 就是类 A 的子类。子类有时也被称为派生类。子类继承了它的超类的所有属性和方法,同时还可以添加自己特有的属性和方法,或者重写超类的方法。
1.1 定义子类
/**
* 定义子类,使用extends关键字
* 经理 -> 员工
* 经理增加一个奖金字段以及用于设置这个字段的方法
*/
public class Manager extends Employee{
private BigDecimal bonus;
public BigDecimal getBonus() {
return bonus;
}
public void setBonus(BigDecimal bonus) {
this.bonus = bonus;
}
}
1.2 覆盖方法
超类中有些方法对于子类并不一定适用,例如Manager类中的getSalary方法应该返回薪水和奖金的总和。为此,需要提供一个新的方法来覆盖超类中的这个方法
@Override
public BigDecimal getSalary() {
return super.getSalary().add(this.getBonus());
}
1.3 子类构造器
public Manager(String name, LocalDate hireDay, BigDecimal salary, BigDecimal bonus) {
super(name, hireDay, salary);
this.bonus = bonus;
}
super(name, hireDay, salary);是调用超类Employee中带有name,hireDay,salary参数的构造器的简写形式
由于子类不能直接访问超类的私有字段,所以必须通过一个构造器来初始化这些字段,利用super语法调用这个构造器
使用super调用构造器的语句必须是子类构造器的第一条语句
1.4 程序清单
public class Employee {
private String name;
private LocalDate hireDay;
private BigDecimal salary;
public Employee(String name, LocalDate hireDay, BigDecimal salary) {
this.name = name;
this.hireDay = hireDay;
this.salary = salary;
}
public LocalDate getHireDay() {
return hireDay;
}
public void setHireDay(LocalDate hireDay) {
this.hireDay = hireDay;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public BigDecimal getSalary() {
return salary;
}
public void setSalary(BigDecimal salary) {
this.salary = salary;
}
}
package com.xzydot._extends._01_class_superClass_subClass;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Date;
/**
* 定义子类,使用extends关键字
* 经理 -> 员工
* 经理增加一个奖金字段以及用于设置这个字段的方法
*/
public class Manager extends Employee{
private BigDecimal bonus;
public Manager(String name, LocalDate hireDay, BigDecimal salary) {
super(name, hireDay, salary);
}
@Override
public BigDecimal getSalary() {
return super.getSalary().add(this.getBonus());
}
public BigDecimal getBonus() {
return bonus;
}
public void setBonus(BigDecimal bonus) {
this.bonus = bonus;
}
}
public class ManagerTest {
public static void main(String[] args) {
var carl = new Manager("Carl", LocalDate.now(), new BigDecimal("15000.12"));
carl.setBonus(new BigDecimal("5000.12"));
var staff = new Employee[3];
staff[0] = carl;
staff[1] = new Employee("xiaowang", LocalDate.of(2020, 6, 30), new BigDecimal("10000"));
staff[2] = new Employee("xiaoxi", LocalDate.of(2023, 6, 30), new BigDecimal("10000"));;
for (Employee e : staff) {
System.out.println("name="+e.getName()+" ,salary="+e.getSalary());
}
}
}
1.5 继承层次结构
继承不仅限于一个层次。通常,一个祖先类可以有多个子孙链
1.6 多态
有一个简单规则可以用来判断是否应该将数据设计为继承关系,这就是“is-a"规则,它指出子类的每一个对象也是超类的对象。例如,每个经理都是员工,因此,将Manager类设计为Employee类的子类是合理的,反之,并不是每一个员工都是经理。
“is-a"规则另一种表述是替换原则,程序中需要超类对象的任何地方都可以使用子类对象替换 Java中的多态和里氏替换原则都是面向对象编程的关键原则,它们之间有联系,但又有一些区别。
多态
多态是一种允许你使用父类引用指向子类对象,并通过这个引用调用在父类中定义、在子类中重写的方法的特性。多态是面向对象编程的三大特性之一(封装、继承和多态),它让我们可以编写出更灵活、更具扩展性的代码。
例如:
class Animal {
void talk() {
System.out.println("Animal talk");
}
}
class Dog extends Animal {
@Override
void talk() {
System.out.println("Dog bark");
}
}
public class Test {
public static void main(String[] args) {
Animal a = new Dog();
a.talk(); // 输出 "Dog bark"
}
}
在这个例子中,Dog
是 Animal
的子类,而 Dog
重写了 Animal
的 talk
方法。我们创建了一个 Dog
对象,但是将其赋给了一个 Animal
类型的引用 a
,然后通过这个引用调用 talk
方法,实际上调用的是 Dog
类的 talk
方法。这就是多态。
里氏替换原则
“程序中需要超类对象的任何地方都可以使用子类对象替换”,正是里氏替换原则(Liskov Substitution Principle)的核心观点。
为了更好地理解这个原则,我们可以通过一个简单的例子来说明。
假设我们有一个 Animal
类和一个继承自 Animal
类的 Dog
类:
public class Animal {
public void eat() {
System.out.println("The animal eats.");
}
}
public class Dog extends Animal {
@Override
public void eat() {
System.out.println("The dog eats dog food.");
}
public void bark() {
System.out.println("The dog barks.");
}
}
然后,我们有一个函数 feedAnimal
,它接受一个 Animal
类型的参数:
public void feedAnimal(Animal animal) {
animal.eat();
}
根据里氏替换原则,我们可以将需要 Animal
对象的地方(即 feedAnimal
方法的参数)替换为 Dog
对象:
Dog dog = new Dog();
feedAnimal(dog); // 输出 "The dog eats dog food."
这里,Dog
类的对象 dog
成功地替换了 Animal
类的对象,并且 feedAnimal
方法依然可以正确执行。
因此,这个原则是说,如果你的代码需要一个超类的对象,那么你应该可以用一个子类的对象来替换,而不影响代码的执行。
1.7 理解方法调用
C x = new C(); //x为类c的一个对象
x.f(args);
重载解析
这个过程也被称为重载解析。当编译器遇到一个方法调用时,它首先需要确定调用的是哪一个方法。
- 确定方法名:首先,编译器查看调用的方法名。如果在类或其超类中找不到这个方法名,那么编译器将报错。
- 查找可能的方法:然后,编译器列出所有具有该方法名的方法。这包括类本身的所有方法(不论是否私有),以及从超类继承的所有可以访问的方法(私有方法不能被子类访问)。
- 匹配方法参数:接下来,编译器检查方法调用中传递的参数,并试图找到一个匹配的方法。在查找的过程中,编译器会考虑参数的数量、类型和顺序。
- 选择合适的方法:如果找到了一个完全匹配的方法,那么编译器就会选择这个方法。如果没有找到完全匹配,但找到了可以通过自动类型转换匹配的方法,那么编译器会选择最接近的那个。如果找不到任何匹配的方法,或者有多个方法都能匹配,那么编译器将报错。
这个过程是在编译时完成的。一旦编译器确定了需要调用的方法,它就会在生成的字节码中直接调用这个方法。在运行时,虚拟机只需按照编译器解析的结果来执行相应的方法,不需要再次进行解析。
需要注意的是,这个过程与虚拟机在运行时处理方法调用的过程是不同的。在运行时,虚拟机处理方法调用的过程可能涉及到动态绑定(或称为延迟绑定、运行时绑定),这是Java实现多态的一种机制。在这种情况下,具体调用哪个方法将取决于对象的实际类型,而不仅仅是声明类型。
Java中的方法调用在运行时确实可能涉及到动态绑定,也被称为运行时绑定或者延迟绑定。这是一种典型的多态行为,允许我们在运行时决定具体执行哪个方法。
动态绑定
- 重载解析(Overload Resolution):这是在编译时进行的过程。当一个方法被调用时,编译器需要确定调用哪一个具体的方法。如果在类中存在多个具有相同名称但参数列表不同的方法(也就是重载的方法),编译器需要通过方法的名称以及参数的数量、类型和顺序来确定调用哪个方法。编译器会选择与实际参数最为匹配的方法。如果找不到任何匹配的方法,或者有多个方法都能匹配,那么编译器将报错。
- 动态绑定(Dynamic Binding):这是在运行时进行的过程。在Java中,如果一个方法在超类中定义,并在子类中被重写,那么虚拟机需要在运行时确定应该调用哪个版本的方法。具体调用哪个版本的方法取决于对象的实际类型,而不仅仅是它被声明的类型。这就是动态绑定的含义。这种机制实现了Java的多态性,允许我们编写更加灵活和可扩展的代码。
这两个过程虽然都与方法调用相关,但是工作在不同的阶段,处理的问题也不同。重载解析关注的是如何在多个具有相同名称但参数不同的方法中选择一个合适的方法,而动态绑定关注的是如何在一个方法在超类和子类中都有定义时,选择合适的方法版本进行调用。
1.8 阻止继承:final类和方法
可以使用final
关键字来阻止继承。final
可以修饰类、方法和变量。
- final类:如果一个类被声明为
final
,那么这个类不能被继承。也就是说,其他的类不能使用extends
关键字来继承这个类。 - final方法:如果一个方法被声明为
final
,那么这个方法不能被重写(override)。也就是说,子类不能定义一个和父类中的final方法具有相同方法名、相同参数列表的方法。
如果将一个类声明为final,只有其中方法自动地成为final,而不包括字段
早期的java版本中,有些程序员为了避免动态绑定带来的系统开销而使用final关键字,如果一个方法没有被覆盖并且很短,编译器就能够对它进行优化处理,无需进行运行时的动态绑定,从而减少一定的系统开销。
然而,随着JVM的进步,特别是JIT编译器的发展,这样的优化已经变得不那么必要了。
JIT编译器会对代码进行分析,找出那些被频繁调用的方法(也称为热点代码),并对这些代码进行优化。优化方式包括将方法的字节码转换成机器代码,减少方法调用的开销,以及方法的内联等。如果一个方法很短并且频繁被调用,那么即使这个方法没有被声明为final
,JIT编译器也可能将它内联,减少方法调用的开销。
所以在现代的Java版本中,程序员一般不会使用final
关键字来优化方法调用了。在决定是否使用final
关键字时,程序员会考虑到final
关键字对代码可读性和可维护性的影响,而不只是性能的问题。
1.9 强制类型转换
Java 中的类型转换分为两种:自动类型转换(隐式)和强制类型转换(显式)。
例如,我们有一个 double 类型的数,我们想将它转换为 int 类型的数,我们可以这样做:
double myDouble = 9.78;
int myInt = (int) myDouble; // This will be 9
在这个例子中,double 类型的 myDouble 被强制转换为 int 类型,转换后的结果存储在 myInt 中。因为 int 是没有小数部分的,所以原来的 0.78 部分被丢弃了。
我们不仅可以强制转换基本数据类型,还可以强制转换引用类型,例如类或接口类型。但是,有一些限制和规则需要遵守:
- 只有当两种类型有继承关系时,才能进行强制类型转换。例如,如果类B是类A的子类,那么可以将B类型的对象转换为A类型(向上转型,向上转型其实并不需要显式转换),也可以将A类型的对象转换为B类型(向下转型,向下转型必须显式转换)。
- 在向下转型时,如果对象不是正确的类型,会在运行时抛出一个ClassCastException异常。为了避免这种情况,我们可以使用
instanceof
关键字先检查对象是否是正确的类型。
class A {
public void sayHello() {
System.out.println("Hello from A");
}
}
class B extends A {
@Override
public void sayHello() {
System.out.println("Hello from B");
}
}
public class Main {
public static void main(String[] args) {
A a = new B(); // 向上转型,实际的对象类型是B
a.sayHello(); // 输出 "Hello from B"
// 向下转型,将A类型的a转换为B类型
if (a instanceof B) {
B b = (B) a;
b.sayHello(); // 输出 "Hello from B"
}
}
}
在这个例子中,a
实际上是一个B
类型的对象,因此向下转型是安全的。如果a
不是B
类型的对象,那么向下转型就会抛出ClassCastException
。
大多数情况不需要向下转型,因为实现多态性的动态绑定机制能够自动的找到正确方法。一般情况下,最好尽量少用强制类型转换和instanceof操作符。
1.10 instanceof模式匹配-java14
在Java 14及更高版本中,instanceof
关键字支持模式匹配,这使得我们在检查对象类型的同时,可以自动将对象转换为目标类型。
在传统的instanceof
使用中,我们必须显式地将对象转换为目标类型,如下面的例子:
if (obj instanceof String) {
String str = (String) obj;
System.out.println(str.length());
}
但是在Java 14及更高版本中,我们可以使用模式匹配的instanceof
,这样就无需显式转换了:
Object obj = "Hello";
if (obj instanceof String str) {
// 在此块中,str已经被自动转换为String类型
System.out.println(str.length());
}
在这个例子中,如果obj
是String
类型的实例,那么obj
就会被自动转换为String
类型,并赋值给str
变量。
1.11 受保护访问
protected
是一种访问修饰符,用于修饰类的成员变量和方法。受保护的成员和方法对于同一包中的其他类以及所有子类都是可访问的。
这里是对四种Java访问修饰符的总结:
private
:该成员只能在其定义的类中被访问。- (无修饰符, 通常称为 "package-private" 或 "default"):该成员可以在其定义的类中被访问,也可以在相同包的其他类中被访问。
protected
:该成员可以在其定义的类中被访问,也可以在相同包的其他类中被访问,以及在所有子类中被访问。public
:该成员在所有地方都可以被访问。
2. Object-所有类的超类
2.1 Object类型的变量
Object
类型的变量可以引用任何类型的对象。这是因为Java中所有的类都是Object
类的子类,所以一个Object
类型的变量可以指向任何对象。只有基本类型不是对象
2.2 equals方法
equals
方法用于比较两个对象的内容是否相等。这个方法在java.lang.Object
类中定义,所有的类都继承了这个方法。默认情况下,equals
方法的行为与==
运算符相同,即它比较两个对象的引用是否相同。
然而,很多类重写(override)了equals
方法以便比较对象的内容。例如,java.lang.String
类就重写了equals
方法,所以两个内容相同的字符串,即使它们在内存中位于不同的位置,equals
方法也会返回true
。
String s1 = new String("Hello");
String s2 = new String("Hello");
System.out.println(s1.equals(s2)); // 输出 true
当你重写equals
方法时,通常也需要重写hashCode
方法,以维持hashCode
方法的通用约定,即相等的对象必须有相等的哈希码。否则,这可能会导致在使用某些Java集合类(如HashSet
或HashMap
)时出现问题。
2.3 相等测试与继承
Java 中的相等测试通常与继承有关,特别是当你重写了 equals
方法。如果你的类有可能成为其他类的超类,那么对 equals
方法的处理尤为重要。
在定义 equals
方法时,你可能会倾向于使用 instanceof
操作符来确定两个对象是否具有相同的类。然而,如果你的类被继承,这可能会导致问题。子类可能添加了新的字段,那么即使超类的 equals
方法返回 true
,子类的 equals
方法也可能返回 false
,这违反了 equals
方法的对称性原则。
考虑以下代码:
public class Super {
private int superVal;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Super)) return false;
Super aSuper = (Super) o;
return superVal == aSuper.superVal;
}
//... hashCode and other methods
}
public class Sub extends Super {
private int subVal;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Sub)) return false;
if (!super.equals(o)) return false;
Sub aSub = (Sub) o;
return subVal == aSub.subVal;
}
//... hashCode and other methods
}
在这个例子中,如果你有一个 Super
对象和一个 Sub
对象,而这两个对象的 superVal
值相等,但 subVal
值不等。那么 Super
类的 equals
方法会返回 true
,但是 Sub
类的 equals
方法会返回 false
。
这就是为什么在 equals
方法中,你应该检查对象的实际类是否与当前对象的类相等,而不是使用 instanceof
操作符。 这样可以避免出现这种情况,确保 equals
方法的对称性。
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Super aSuper = (Super) o;
return superVal == aSuper.superVal;
}
然后,在 Sub
类的 equals
方法中,你还需要调用 super.equals(o)
来检查超类的字段是否相等。
但这也意味着,一旦一个类被设计为可能成为其他类的超类,并且重写了 equals
方法,那么它就不应该是一个可变类(即它的状态不能改变),否则子类会面临很大的挑战来维护 equals
方法的约定。
equals()
方法需要满足一些特性,被称为 equals 的契约,其中包括对称性:如果 x.equals(y)
返回 true
,那么 y.equals(x)
也应返回 true
。
当我们使用 instanceof
操作符时,存在一个问题:它并不检查两个对象是否属于同一确切的类,而是检查一个对象是否是另一个对象类的实例或子类实例。这样在处理父类和子类的实例时可能会破坏 equals 方法的对称性。
考虑一个 Employee
类和一个 Manager
类(Manager
类是 Employee
类的子类)。假设我们在 Employee
类中重写 equals()
方法并使用 instanceof
操作符检查传入的对象是否是 Employee
类型。当你比较一个 Employee
对象和一个 Manager
对象时,employee.equals(manager)
会返回 true
(因为 Manager
是 Employee
的实例),但 manager.equals(employee)
可能返回 false
(因为 Manager
的 equals()
方法可能会检查一些特定于 Manager
的字段)。
这违反了 equals 方法的对称性,因此,最好检查两个对象是否属于同一个确切的类,而不是使用 instanceof
操作符。这样可以确保 equals()
方法对于子类和超类的实例都表现得一致。
2.4 hashCode方法
Java中的hashCode()
方法用于获取对象的哈希码,这个哈希码主要用于数据结构中,比如HashMap、HashSet等。它返回的是一个int整数。这些基于哈希的集合使用hashCode()
来确定对象存储在内部的位置。
hashCode()
函数的主要规则包括:
- 在Java应用程序的一次执行过程中,如果对象的equals方法的比较操作用到的信息没有被修改,那么,对这同一个对象调用多次,
hashCode
方法必须始终如一地返回同一个整数。 - 如果两个对象根据
equals(Object)
方法比较是相等的,那么调用这两个对象中任一对象的hashCode
方法必须产生同样的整数结果。 - 如果两个对象根据
equals(java.lang.Object)
方法比较是不相等的,那么调用这两个对象中任一对象的hashCode
方法,不要求必须产生不同的整数结果。但是,开发人员应该意识到,对于不同的对象产生截然不同的整数结果,有可能提高哈希表的性能。
当你重写equals()
方法时,通常也需要重写hashCode()
,以便保持hashCode的规则。也就是说,如果两个对象相等(通过equals()
方法比较),则它们的哈希码(由hashCode()
方法生成)也必须相等。如果你不保持这个规则,那么当你的对象被用于哈希集合时,将会违反集合的规则,导致对象的存储和检索行为不正确。
一个简单的hashCode
方法可以如下:
@Override
public int hashCode() {
int result = 17;
result = 31 * result + someField; // 对于你想要在equals方法中使用的每一个重要的字段f,都需要进行如下操作:
// 1. 为f计算int类型的散列码 c:
// a. 如果该字段是boolean类型,则计算(f ? 1 : 0);
// b. 如果该字段是byte、char、short或者int类型,则计算(int)f;
// c. 如果该字段是long类型,则计算(int)(f ^ (f>>>32));
// d. 如果该字段是float类型,则计算Float.floatToIntBits(f);
// e. 如果该字段是double类型,则计算Double.doubleToLongBits(f),然后按照步骤2.c,再散列得到的long型散列码;
// f. 如果该字段是一个对象引用,并且equals方法通过递归地调用equals的方式来比较这个字段,则同样对这个字段递归地调用hashCode。如果需要更复杂的比较,则为这个字段计算一个“范式”,然后针对这个范式调用hashCode。如果这个字段的值为null,则返回0(或者其他某个常数,但是一般选择0);
// g. 如果这个字段是一个数组,则把每一个元素当作单独的字段来处理。也就是说,递归地应用上述规则,对每个重要的元素计算一个散列码,然后根据步骤2.a的方式把这些散列值组合起来。如果每个元素都很重要,可以利用Arrays.hashCode方法。
// 2. 做如下操作:result = 31 * result + c;
return result;
}
请注意,上述算法中的常数31有很好的性能表现,并且因为其是奇素数,所以在某些乘法溢出的情况下能够保持更好的特性。但是,选择31并非必须的,你可以选择其他数字,只要它是一个非零常数即可。
2.5 toString方法
在Java中,toString()
方法是Object类的一个公共方法,它通常被用于返回一个对象的字符串表示。所有的Java对象都继承自Object类,因此所有Java对象都继承了这个方法。
默认情况下,toString()
方法返回的是对象的类名和对象的哈希码的无符号十六进制表示,这通常是没什么实际意义的一串数字。例如,如果你有一个名为"MyObject"的类,创建了一个名为"myObject"的实例,那么myObject.toString()
方法可能会返回类似"MyObject@5cacab70"这样的字符串。
为了使toString()
方法返回有意义的字符串,通常会在自定义类中重写(override)这个方法。例如,对于一个“Employee”类,你可能想要toString()
方法返回员工的姓名和职位:
public class Employee {
private String name;
private String title;
// ... 其他代码 ...
@Override
public String toString() {
return "Employee[name=" + name + ",title=" + title + "]";
}
}
这样,如果你创建了一个Employee实例并调用其toString()
方法,你会得到一个有意义的字符串,如"Employee[name=John Doe,title=Software Engineer]"。
注意,当你试图直接打印一个对象时,例如System.out.println(myObject)
,实际上调用的就是对象的toString()
方法,所以正确地重写toString()
方法可以帮助提升程序的可读性。
3. 泛型数组列表
因为数组必须要定义大小,不能动态修改数组大小,因此有了ArrayList类,与数组类似,但在添加或删除元素时,它能够自动地调整容量。
3.1 声明数组列表
在Java中,ArrayList是一种动态数组,可以自动增长和缩小。你可以在ArrayList中添加任何类型的数据,但是通常我们会使用Java的泛型特性来限制ArrayList可以包含的数据类型。
下面是声明一个只能包含String类型的ArrayList的方式:
ArrayList<String> list = new ArrayList<String>();
在这个例子中,<String>
是一个泛型参数,它告诉编译器我们的ArrayList只能包含String对象。如果你试图向这个列表中添加非String类型的对象,编译器将会报错。
注意,在Java 7及以后的版本中,你可以使用菱形操作符(<>
)来省略右边的泛型参数:
ArrayList<String> list = new ArrayList<>();
这里,编译器可以推断出右边的泛型参数类型,所以你不必显式地写出来。这被称为类型推断,它可以让你的代码更简洁,更易于阅读。
在java10中,最好使用var关键字以避免重复写类名:
var staff = new ArrayList<Employee>();
在Java中,ArrayList类提供了一系列的方法,让我们可以轻松地对数组列表进行操作。下面是一些最常用的ArrayList方法:
add(E element)
: 将指定的元素追加到此列表的末尾。add(int index, E element)
: 在此列表中的指定位置插入指定的元素。remove(int index)
: 移除此列表中指定位置的元素。remove(Object o)
: 如果存在的话,从此列表中移除第一次出现的指定元素。get(int index)
: 返回此列表中指定位置上的元素。set(int index, E element)
: 用指定的元素替换此列表中指定位置的元素。size()
: 返回此列表中的元素数。clear()
: 移除此列表中的所有元素。isEmpty()
: 如果此列表中没有元素,则返回 true。contains(Object o)
: 如果此列表包含指定的元素,则返回 true。indexOf(Object o)
: 返回此列表中首次出现的指定元素的索引,或如果此列表不包含元素,则返回 -1。lastIndexOf(Object o)
: 返回此列表中最后出现的指定元素的索引,或如果此列表不包含元素,则返回 -1。toArray()
: 返回一个包含此列表中所有元素的数组。
3.2 访问数组列表元素
在 Java 中,遍历 ArrayList 的方法有多种,下面是最常见的几种:
- for-each循环(增强型for循环)
ArrayList<String> list = new ArrayList<>();
list.add("Alice");
list.add("Bob");
list.add("Charlie");
for (String item : list) {
System.out.println(item);
}
- 传统的for循环
ArrayList<String> list = new ArrayList<>();
list.add("Alice");
list.add("Bob");
list.add("Charlie");
for (int i = 0; i < list.size(); i++) {
System.out.println(list.get(i));
}
- 迭代器
ArrayList<String> list = new ArrayList<>();
list.add("Alice");
list.add("Bob");
list.add("Charlie");
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String item = it.next();
System.out.println(item);
}
- Java 8 中的 forEach() 方法与 Lambda 表达式
ArrayList<String> list = new ArrayList<>();
list.add("Alice");
list.add("Bob");
list.add("Charlie");
list.forEach(item -> System.out.println(item));
这四种方法各有优点,可以根据具体的需求选择最适合的遍历方式。
3.3 类型化与原始数组列表的兼容性
Java的泛型设计的一个主要目标是向后兼容。这就意味着,新的泛型代码可以与老的非泛型代码协同工作。但是,这种向后兼容也带来了一些限制,并有可能引发一些警告。
让我们来看一个例子:
ArrayList<Integer> intList = new ArrayList<>();
ArrayList rawList = intList; // Ok, but causes unchecked warning
在上述例子中,类型化的ArrayList(也就是泛型版本)赋值给了一个原始类型的ArrayList,这是允许的,但会导致一个"unchecked"警告。这是因为,赋值给原始类型的ArrayList之后,编译器就无法再检查元素类型,可能导致放入错误类型的元素,这就会引发类型错误。
另一方面,原始类型的ArrayList也可以赋值给一个泛型版本的ArrayList,但是这样做可能会引发一个"unchecked"警告:
ArrayList rawList = new ArrayList();
ArrayList<Integer> intList = rawList; // Ok, but causes unchecked warning
因此,虽然类型化的数组列表(泛型版本)和原始数组列表在语法上是兼容的,但为了类型安全,最好不要混合使用它们。在使用ArrayList或其他泛型集合时,应尽量指定元素类型,避免使用原始类型的集合。
4. 对象包装器与自动装箱
在Java中,每个基本数据类型都有一个对应的对象包装类。例如:
byte
-Byte
short
-Short
int
-Integer
long
-Long
float
-Float
double
-Double
char
-Character
boolean
-Boolean
等等。包装类在许多情况下非常有用,例如需要在数据结构中存储基本类型数据(如ArrayList<int>
是不允许的,但可以使用ArrayList<Integer>
)或者需要对基本类型进行对象操作时。
自动装箱(Autoboxing)和自动拆箱(Unboxing)是Java 5引入的特性,让基本类型和包装类之间的转换变得更加容易。自动装箱是基本类型自动转换为对应的包装类型,自动拆箱是包装类型自动转换为对应的基本类型。
例如:
// 自动装箱:把int类型转成Integer类型
int i = 10;
Integer iInteger = i; // 自动装箱
// 自动拆箱:把Integer类型转成int类型
Integer jInteger = new Integer(8);
int j = jInteger; // 自动拆箱
在编译器进行自动装箱和拆箱时,实际上会调用对应的包装类和基本类型之间的转换方法。例如,对于int类型和Integer类型:
Integer iInteger = i;
实际上会被编译器转换为Integer iInteger = Integer.valueOf(i);
int j = jInteger;
实际上会被编译器转换为int j = jInteger.intValue();
这种自动装箱和拆箱的特性使得编写Java代码时,我们可以更关注业务逻辑,而不需要过于关注基本类型和包装类型之间的转换。
5. 参数个数可变的方法
你可以创建一个接收可变数量参数的方法,这被称为可变参数方法。这是通过使用三点(...)语法实现的。这种方法在你不知道要传递多少个参数时非常有用。
例如,下面是一个计算一系列整数和的方法:
public static int sum(int... numbers) {
int total = 0;
for (int number : numbers) {
total += number;
}
return total;
}
在这个例子中,numbers
是一个 int
类型的数组。当你调用 sum
方法时,你可以传递任意数量的 int
参数。例如:
int total = sum(1, 2, 3, 4, 5); // Returns 15
在上述代码中,参数 1, 2, 3, 4, 5
会被自动封装为一个数组,然后传递给 sum
方法。
需要注意的是,可变参数必须是方法签名中的最后一个参数。但是,你的方法可以有其他的参数。例如:
public static String concat(String separator, String... words) {
StringBuilder sb = new StringBuilder();
for (String word : words) {
if (sb.length() > 0) {
sb.append(separator);
}
sb.append(word);
}
return sb.toString();
}
在这个例子中,concat
方法接收一个固定的 separator
参数和一个可变的 words
参数。你可以像下面这样调用这个方法:
String sentence = concat(" ", "Hello", "world!"); // Returns "Hello world!"
6. 抽象类
抽象类(Abstract Class)是一种不能被实例化(不能创建对象)的类。抽象类被用作其他类的基类(超类)。抽象类可以包含抽象方法(没有实现的方法)和非抽象方法(已实现的方法)。
声明一个抽象类,你需要使用 abstract
关键字,如下所示:
public abstract class Animal {
//...
}
在抽象类中,你可以声明抽象方法,这些方法在抽象类中没有具体的实现。抽象方法也需要 abstract
关键字,并且它们以分号结束(没有方法体),如下所示:
public abstract class Animal {
public abstract void sound();
}
如果一个类继承了抽象类,那么它必须实现抽象类中的所有抽象方法。除非该类也是一个抽象类,如下所示:
public class Dog extends Animal {
public void sound() {
System.out.println("Woof");
}
}
在上述例子中,Dog
类继承了 Animal
抽象类,并实现了 sound
方法。
需要注意的是,一个类只能继承一个抽象类,因为 Java 不支持多重继承。但是一个类可以实现多个接口,这是一种 Java 提供的绕过多重继承问题的方式。
抽象类主要有以下几个用途:
- 提供通用的方法实现:抽象类可以提供一些通用的方法实现,这些方法在所有继承该抽象类的子类中都可以使用。这可以避免在每个子类中重复相同的代码。
- 定义接口:抽象类可以定义一组方法,这些方法构成了一个接口,所有继承该抽象类的子类都必须实现这些方法(除非子类也是抽象类)。这可以确保所有的子类都遵循相同的行为协议。
- 定义数据类型:抽象类本身是一个数据类型,可以用来声明变量和创建数组。尽管不能创建抽象类的实例,但可以创建指向继承该抽象类的子类对象的引用。
- 封装设计:抽象类可以封装设计的复杂性。抽象类中的方法可以定义为 protected,这样只有子类才能访问它们。这可以把类的内部工作机制隐藏起来,使得使用类的代码更加简洁和易于理解。
- 提供一个中间层:抽象类可以作为继承层次结构中的中间层,帮助我们更好地组织和管理代码。例如,我们可以把一些通用的属性和方法放在一个抽象类中,然后让具有共同特性的类继承这个抽象类。
总的来说,使用抽象类可以帮助我们更好地进行代码复用,定义接口协议,封装复杂性,以及更好地组织和管理代码。
7. 枚举类
枚举类(Enum Class)是一种特殊的类,用于表示一组固定的常量。枚举类可以包含常量、字段和方法,并且可以像其他类一样进行扩展和实现接口。
以下是一个简单的枚举类的例子:
public enum Day {
MONDAY,
TUESDAY,
WEDNESDAY,
THURSDAY,
FRIDAY,
SATURDAY,
SUNDAY
}
在上述例子中,Day
是一个枚举类,包含了一周的每一天作为枚举常量。每个常量都是唯一的,并以大写字母表示。你可以像下面这样使用枚举常量:
Day today = Day.MONDAY;
System.out.println(today); // 输出 "MONDAY"
枚举类可以有字段、构造函数和方法,就像普通的类一样。例如,你可以为枚举常量添加额外的信息:
public enum Day {
MONDAY("Monday", "Mon"),
// ...
private final String fullName;
private final String shortName;
private Day(String fullName, String shortName) {
this.fullName = fullName;
this.shortName = shortName;
}
public String getFullName() {
return fullName;
}
public String getShortName() {
return shortName;
}
}
在这个例子中,每个枚举常量都有一个全名和一个简称。通过构造函数来设置这些字段的值,并通过方法获取它们。
枚举类的构造方法在声明枚举常量时自动调用,并且只在枚举常量被创建时调用一次。你无法直接调用枚举类的构造方法,而是通过声明枚举常量来间接调用构造方法。
例如,考虑下面的枚举类示例:
public enum Day {
MONDAY("Monday"),
TUESDAY("Tuesday"),
WEDNESDAY("Wednesday"),
THURSDAY("Thursday"),
FRIDAY("Friday"),
SATURDAY("Saturday"),
SUNDAY("Sunday");
private final String dayName;
private Day(String dayName) {
this.dayName = dayName;
}
public String getDayName() {
return dayName;
}
}
在上述例子中,枚举常量 MONDAY、TUESDAY、WEDNESDAY 等都会调用构造方法 Day(String dayName)
来初始化它们自己的属性。当你使用枚举常量时,它们已经被创建并且构造方法已经被调用了。例如:
Day monday = Day.MONDAY;
System.out.println(monday.getDayName()); // 输出 "Monday"
注意,枚举常量的创建方式是通过枚举类名后跟括号中的参数来调用构造方法。每个枚举常量在声明时都会传入相应的参数,并且构造方法会根据这些参数来初始化枚举常量的属性。
8. 密封类-java15
Java 15 引入了密封类(Sealed Class)的概念,用于限制继承层次结构中的类的继承关系。密封类允许开发人员明确指定哪些类可以继承或实现密封类。
通过使用 sealed
关键字来声明一个类为密封类,如下所示:
public sealed class SealedClass permits SubClass1, SubClass2, SubClass3 {
// class body
}
在上述示例中,SealedClass
是一个密封类,它通过 permits
关键字指定了允许继承的子类,即 SubClass1
、SubClass2
和 SubClass3
。只有这些类可以继承或实现 SealedClass
,其他类无法继承或实现它。
密封类的主要目的是为了控制继承层次结构,限制可继承的类的范围。这样可以确保继承的类是在设计者的控制下,并且对于某些特定的操作或功能是有限制的。
需要注意的是,密封类可以有子类,但这些子类必须在密封类的同一个包中。这是为了确保密封类对于继承者的限制是有效的,并且能够对继承关系进行更好的控制。
使用密封类的好处是可以提供更严格的继承关系,并增加代码的安全性和可维护性。它可以防止意外的类扩展和继承,从而提供更好的代码结构和设计。
在Java中,密封类(Sealed Class)是一种限制继承的类,而 final
关键字用于禁止继承,non-sealed
则表示未限制继承。这些关键字的使用有以下几个区别:
- 密封类(Sealed Class):通过
sealed
关键字声明的类是一个密封类,它允许限制哪些类可以继承或实现它。在声明密封类时,需要使用permits
关键字列出允许继承的子类。只有这些子类可以继承或实现密封类,其他类不能继承或实现它。这样可以提供更好的继承层次结构的控制。 final
关键字:使用final
关键字可以禁止类被继承,即它的子类无法继承它。在声明类时,可以将类声明为final
。这意味着该类不能被其他类继承。non-sealed
关键字:如果一个类没有被声明为密封类或final
类,那么它就是一个non-sealed
类,即没有对继承做出限制。任何类都可以继承它。
使用密封类可以在一定程度上控制继承层次结构,只允许特定的类进行继承。而 final
关键字则用于完全禁止继承。而 non-sealed
类没有对继承做出限制,任何类都可以继承它。
9. 反射
Java 反射(Reflection)是一种强大的机制,它允许在运行时检查和操作类、接口、字段、方法等的信息。通过反射,我们可以动态地获取类的信息,并在运行时操作类的实例、调用方法、访问字段等。
Java 反射提供了一组类和接口,主要包括以下核心类:
Class
类:代表一个类或接口,在运行时可以获取类的各种信息,如字段、方法、构造函数等。Constructor
类:代表类的构造函数,可以用于实例化类的对象。Method
类:代表类的方法,可以通过反射调用类的方法。Field
类:代表类的字段,可以通过反射访问和修改类的字段值。
使用反射可以实现一些动态的功能,例如:
- 动态地创建对象:通过反射可以实例化类的对象,即使在编译时并不知道具体的类名。
- 动态地调用方法:通过反射可以调用类的方法,包括私有方法。
- 动态地访问和修改字段:通过反射可以获取类的字段,并对其进行读取和修改。
- 动态地获取类的信息:通过反射可以获取类的各种信息,如类名、父类、接口、注解等。
使用反射需要谨慎,因为它破坏了类的封装性,并且可能会导致性能上的损失。在大多数情况下,应尽量避免过多地使用反射,而是通过正常的面向对象编程方式解决问题。然而,在某些情况下,如框架开发、动态代理、测试工具等,反射可以提供便利和灵活性。
9.1 Class类
在Java中,Class
类是反射机制的核心类之一,它代表一个类或接口的运行时类型信息。通过 Class
类,我们可以获取并操作类的各种信息,如字段、方法、构造函数等。
有多种方式可以获取 Class
对象,例如:
- 使用
.class
语法:可以通过类名后面加上.class
来获取该类的Class
对象。例如:Class<?> clazz = MyClass.class;
- 使用
Class.forName()
方法:可以通过类的全限定名(包括包名)来获取对应的Class
对象。例如:Class<?> clazz = Class.forName("com.example.MyClass");
- 使用对象的
getClass()
方法:可以通过对象的getClass()
方法获取其运行时的Class
对象。例如:Class<?> clazz = obj.getClass();
一旦获取到了 Class
对象,就可以通过它来获取类的各种信息和进行操作,例如:
- 获取类的名称:
String className = clazz.getName();
- 获取类的修饰符:
int modifiers = clazz.getModifiers();
- 获取类的父类:
Class<?> superClass = clazz.getSuperclass();
- 获取类的接口:
Class<?>[] interfaces = clazz.getInterfaces();
- 获取类的构造函数:
Constructor<?>[] constructors = clazz.getConstructors();
- 获取类的方法:
Method[] methods = clazz.getMethods();
- 获取类的字段:
Field[] fields = clazz.getFields();
等等。
9.2 资源
Class
类提供了几种方法来查找资源(如文件、图像、配置文件等)的位置。这些方法可以根据需要从类路径、文件系统或其他位置加载资源。
以下是一些常用的方法来查找资源:
getResource(String name)
:该方法用于从类的包中获取资源。它会在类的包路径下搜索指定名称的资源,并返回一个URL
对象。
javaCopy code
URL resourceUrl = MyClass.class.getResource("file.txt");
getResourceAsStream(String name)
:该方法与getResource(String name)
类似,但返回一个InputStream
对象,方便对资源进行读取操作。
javaCopy code
InputStream inputStream = MyClass.class.getResourceAsStream("file.txt");
getSystemResource(String name)
:该方法用于从系统类路径中获取资源。它会在系统类路径下搜索指定名称的资源,并返回一个URL
对象。
javaCopy code
URL resourceUrl = ClassLoader.getSystemResource("file.txt");
getSystemResourceAsStream(String name)
:该方法与getSystemResource(String name)
类似,但返回一个InputStream
对象。
javaCopy code
InputStream inputStream = ClassLoader.getSystemResourceAsStream("file.txt");
这些方法会根据指定的名称在类路径或系统类路径中搜索资源。请注意,名称参数可以是相对路径或绝对路径,具体取决于资源在文件系统中的位置。
需要注意的是,这些方法在查找资源时,会根据类加载器的策略进行查找。默认情况下,它们会在当前类所在的包或类路径下查找资源。如果资源不在当前类的包或类路径下,可以使用绝对路径或者指定其他类路径来查找。
使用这些方法,可以方便地在Java程序中查找并加载所需的资源文件。根据资源的位置和加载方式的不同,可以选择适合的方法来获取资源。
类路径(Classpath):类路径是用于查找类文件和资源文件的路径。它是一个包含目录和JAR文件的集合,其中包含了Java程序运行时需要加载的类和资源。类路径可以由多个路径组成,路径之间使用分隔符(如冒号 ":" 或分号 ";")分隔。当Java程序运行时,它会根据类路径查找类和资源文件。
在命令行中运行Java程序时,可以通过 -classpath
或 -cp
选项指定类路径,例如:
java -classpath /path/to/classes:/path/to/lib/library.jar MainClass
在IDE(集成开发环境)中,可以在项目的配置中设置类路径。
系统类路径(System Classpath):系统类路径是Java虚拟机(JVM)在启动时默认的类路径。它包含了JVM运行时需要的核心类库和其他系统级别的类。系统类路径通常由Java安装目录下的库文件组成,如rt.jar
。
系统类路径可以通过系统属性java.class.path
来获取,例如:
String classPath = System.getProperty("java.class.path");
可以使用类加载器(ClassLoader)来动态添加或修改类路径,以满足特定的运行时需求。
类路径和系统类路径都是Java程序查找类和资源文件的重要依据。通过正确配置类路径,可以确保Java程序能够正确加载所需的类和资源。
9.3 利用反射分析类的能力
Java反射机制提供了一种分析类的能力,可以在运行时动态地获取和操作类的信息。
下面是一个示例,展示如何使用Java反射机制来分析类的能力:
import java.lang.reflect.*;
class MyClass {
private String privateField;
public int publicField;
public MyClass() {
}
public MyClass(String privateField, int publicField) {
this.privateField = privateField;
this.publicField = publicField;
}
private void privateMethod() {
System.out.println("Private method called");
}
public void publicMethod() {
System.out.println("Public method called");
}
}
public class ReflectionExample {
public static void main(String[] args) throws Exception {
// 获取类的信息
Class<?> clazz = MyClass.class;
System.out.println("Class name: " + clazz.getName());
System.out.println("Modifiers: " + Modifier.toString(clazz.getModifiers()));
System.out.println("Superclass: " + clazz.getSuperclass().getName());
// 获取字段信息
Field[] fields = clazz.getDeclaredFields();
for (Field field : fields) {
System.out.println("Field name: " + field.getName());
System.out.println("Field type: " + field.getType().getName());
System.out.println("Modifiers: " + Modifier.toString(field.getModifiers()));
System.out.println("-----------");
}
// 获取方法信息
Method[] methods = clazz.getDeclaredMethods();
for (Method method : methods) {
System.out.println("Method name: " + method.getName());
System.out.println("Return type: " + method.getReturnType().getName());
System.out.println("Modifiers: " + Modifier.toString(method.getModifiers()));
System.out.println("-----------");
}
// 创建对象实例并调用方法
Constructor<?> constructor = clazz.getDeclaredConstructor(String.class, int.class);
Object obj = constructor.newInstance("privateValue", 123);
Method method = clazz.getDeclaredMethod("publicMethod");
method.invoke(obj);
// 访问和修改字段值
Field field = clazz.getDeclaredField("privateField");
field.setAccessible(true);
System.out.println("Private field value: " + field.get(obj));
field.set(obj, "newValue");
System.out.println("Private field value after modification: " + field.get(obj));
}
}
在上面的示例中,我们使用反射分析了 MyClass
类的能力。我们获取了类的信息、字段信息和方法信息,并实例化了该类的对象并调用了其中的方法。还演示了如何访问和修改对象的私有字段值。
请注意,使用反射需要处理异常(如 NoSuchMethodException
、IllegalAccessException
、InstantiationException
、InvocationTargetException
、NoSuchFieldException
等)。在示例中,我们简化了异常处理,以便更清晰地展示反射的使用方式。
9.4 使用反射在运行时分析对象
使用Java反射机制,可以在运行时分析对象的结构和行为。以下是一些常见的使用反射分析对象的能力:
获取对象的类信息:通过调用对象的 getClass()
方法,可以获取对象所属类的 Class
对象。通过 Class
对象,可以获取类的名称、修饰符、父类、实现的接口等信息。
获取对象的字段信息:使用反射可以获取对象的字段信息,包括字段的名称、类型、修饰符等。
获取对象的方法信息:通过反射可以获取对象所属类的方法信息,包括方法的名称、参数类型、返回类型、修饰符等。
调用对象的方法:使用反射可以在运行时动态地调用对象的方法,包括公共方法、私有方法和静态方法。
访问和修改对象的字段值:通过反射可以获取和修改对象的字段的值,即使字段是私有的。
下面是一个示例,展示如何使用Java反射在运行时分析对象:
import java.lang.reflect.Field;
import java.lang.reflect.Method;
class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public void sayHello() {
System.out.println("Hello, my name is " + name);
}
}
public class ReflectionExample {
public static void main(String[] args) throws Exception {
Person person = new Person("John", 25);
// 获取对象的类信息
Class<?> clazz = person.getClass();
System.out.println("Class name: " + clazz.getName());
// 获取对象的字段信息
Field[] fields = clazz.getDeclaredFields();
for (Field field : fields) {
field.setAccessible(true);
String fieldName = field.getName();
Object fieldValue = field.get(person);
System.out.println(fieldName + ": " + fieldValue);
}
// 获取对象的方法信息
Method[] methods = clazz.getDeclaredMethods();
for (Method method : methods) {
String methodName = method.getName();
System.out.println("Method name: " + methodName);
}
// 调用对象的方法
Method sayHelloMethod = clazz.getDeclaredMethod("sayHello");
sayHelloMethod.invoke(person);
}
}
在上述示例中,我们创建了一个 Person
类,该类具有 name
和 age
两个私有字段以及一个 sayHello
方法。然后,我们创建了一个 Person
对象并使用反射分析该对象。
我们获取了对象的类信息,并打印了类的名称。然后,我们获取了对象的字段信息,将字段的名称和值打印出来。接下来,我们获取了对象的方法信息,并打印了方法的名称。最后,我们使用反射调用了对象的 sayHello
方法。
Java使用反射在运行时分析对象和利用反射分析类的能力有以下区别:
- 对象 vs 类:使用反射分析对象是指在运行时获取已经存在的对象的信息,如字段值、方法等。而利用反射分析类是指在运行时获取类的信息,如字段、方法、构造函数等。
- 目标不同:使用反射分析对象主要关注于已经存在的对象,以了解对象的结构和行为。而利用反射分析类主要关注于类本身的信息,以了解类的结构和特征。
- 应用场景不同:使用反射分析对象通常用于动态地操作和调用对象的方法和字段,从而实现灵活性和动态性。而利用反射分析类通常用于实现一些高级功能,如框架开发、依赖注入、ORM(对象关系映射)等。
- 操作对象 vs 操作类:使用反射分析对象主要涉及对对象实例的操作,如获取和修改字段值,调用方法等。而利用反射分析类主要涉及对类的静态信息的操作,如获取字段、方法等。
总体来说,使用反射分析对象更注重对象的实例化和行为操作,而利用反射分析类更注重类的结构和特征。两者都是利用反射机制来实现动态性和灵活性,但关注的焦点和应用场景略有不同。
9.5 使用反射编写泛型数组代码
在Java中,由于泛型的类型擦除特性,直接创建泛型数组是不允许的。但可以通过使用反射来绕过这个限制。下面是一个示例代码,演示如何使用反射编写泛型数组代码:
import java.lang.reflect.Array;
public class GenericArrayExample<T> {
private T[] array;
public GenericArrayExample(Class<T> elementType, int size) {
@SuppressWarnings("unchecked")
final T[] array = (T[]) Array.newInstance(elementType, size);
this.array = array;
}
public void set(int index, T value) {
array[index] = value;
}
public T get(int index) {
return array[index];
}
public static void main(String[] args) {
GenericArrayExample<String> example = new GenericArrayExample<>(String.class, 5);
example.set(0, "Hello");
example.set(1, "World");
String value = example.get(0);
System.out.println(value);
}
}
在上述示例中,我们创建了一个名为 GenericArrayExample
的泛型类,它接受一个类型参数 T
。在类的构造函数中,我们使用反射创建了一个泛型数组,并将其赋值给类的实例变量 array
。
使用 Array.newInstance()
方法创建了一个具有指定元素类型和大小的数组。由于类型擦除的限制,我们需要使用 @SuppressWarnings("unchecked")
注解来消除编译器的警告。
然后,我们可以使用 set()
方法设置数组中的元素值,使用 get()
方法获取数组中的元素值。
在示例的 main()
方法中,我们创建了一个 GenericArrayExample
对象,并演示了如何设置和获取数组元素。
需要注意的是,尽管通过反射创建了泛型数组,但我们仍然需要在编写代码时谨慎处理类型安全性,并避免对数组中的元素做不兼容的操作。此外,由于泛型数组的限制和类型擦除的特性,这种用法在实际开发中并不常见,应尽量避免使用泛型数组。
10. 继承的设计技巧
在Java中,继承是一种重要的面向对象编程的机制,它允许子类继承父类的属性和方法,并且可以通过扩展和重写来实现代码的复用和扩展。下面是一些Java继承的设计技巧:
- 遵循单一职责原则:每个类应该只有一个单一的责任。避免创建过大、复杂的继承层次结构,保持继承关系的简洁和清晰。
- 使用抽象类或接口:抽象类和接口是实现继承和多态的重要工具。通过抽象类或接口定义通用的行为和属性,然后让具体的子类来实现具体的功能。
- 合理使用继承和组合:继承和组合是实现代码复用的两种主要方式。在设计时,要根据情况选择合适的方式。如果子类与父类之间是"是一个"的关系,并且需要继承父类的行为和属性,可以使用继承。如果子类与父类之间是"有一个"的关系,并且需要复用父类的功能,可以使用组合。
- 避免过度继承:避免创建过深的继承层次结构,因为过度继承会增加代码的复杂性和维护成本。尽量保持继承层次的简洁和扁平,确保继承关系的清晰和易于理解。
- 使用模板方法模式:模板方法模式是一种通过继承来实现代码重用的设计模式。在父类中定义一个模板方法,然后让子类来实现具体的细节。这样可以在不改变算法结构的情况下,定制特定的行为。
- 使用多态:多态是面向对象编程的重要概念,它允许同一个方法根据不同的对象表现出不同的行为。通过多态,可以提高代码的可扩展性和可维护性。
- 遵循里氏替换原则:子类应该能够替换掉父类并且不产生意外的行为。在使用继承时,要确保子类与父类之间的接口和行为一致,避免破坏代码的可靠性。
- 将公共操作和字段放在超类中
- 不要使用受保护字段
- 使用继承实现“is-a”关系
- 除非所有继承的方法都有意义,否则不要使用继承
- 覆盖方法时,不要改变预期行为
- 使用多态,而不要使用类型信息
- 不要滥用反射