Skip to content

Latest commit

 

History

History
491 lines (371 loc) · 27.1 KB

File metadata and controls

491 lines (371 loc) · 27.1 KB

十八、编写高质量代码的最佳实践

当程序员相互交谈时,他们经常使用非程序员无法理解的术语,或者不同编程语言的程序员模糊理解的术语。但是那些使用相同编程语言的人彼此理解得很好。有时也可能取决于程序员的知识水平。一个新手可能不明白一个有经验的程序员在说什么,而一个有经验的同事则点头以示回应

在本章中,读者将了解一些 Java 编程术语,即描述某些特性、功能、设计解决方案等的 Java 习惯用法。读者还将学习设计和编写应用代码的最流行和最有用的实践。在本章结束时,读者将对其他 Java 程序员在讨论他们的设计决策和使用的功能时所谈论的内容有一个坚实的理解。

本章将讨论以下主题:

  • Java 习惯用法及其实现和用法
  • equals()hashCode()compareTo()clone()方法
  • StringBufferStringBuilder
  • trycatchfinally条款
  • 最佳设计实践
  • 代码是为人编写的
  • 测试:通往高质量代码的最短路径

Java 习惯用法及其实现和用法

除了服务于专业人员之间的交流方式之外,编程习惯用法也是经过验证的编程解决方案和常用实践,它们不是直接从语言规范中派生出来的,而是从编程经验中产生的,我们将讨论最常用的习惯用法,您可以在 Java 官方文档中找到并研究完整的习惯用法列表。

equals()hashCode()方法

java.lang.Object类中equals()hashCode()方法的默认实现如下:

public boolean equals(Object obj) {
    return (this == obj);
}
/**
* Whenever it is invoked on the same object more than once during
* an execution of a Java application, the hashCode method
* must consistently return the same integer...
* As far as is reasonably practical, the hashCode method defined
* by class Object returns distinct integers for distinct objects.
*/
@HotSpotIntrinsicCandidate
public native int hashCode();

如您所见,equals()方法的默认实现只比较指向存储对象的地址的内存引用。类似地,您可以从注释(引用自源代码)中看到,hashCode()方法为同一个对象返回相同的整数,为不同的对象返回不同的整数。让我们用Person类来演示它:

public class Person {
    private int age;
    private String firstName, lastName;
    public Person(int age, String firstName, String lastName) {
        this.age = age;
        this.lastName = lastName;
        this.firstName = firstName;
    }
    public int getAge() { return age; }
    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
}

下面是默认的equals()hashCode()方法的行为示例:

Person person1 = new Person(42, "Nick", "Samoylov");
Person person2 = person1;
Person person3 = new Person(42, "Nick", "Samoylov");
System.out.println(person1.equals(person2)); //prints: true
System.out.println(person1.equals(person3)); //prints: false
System.out.println(person1.hashCode());      //prints: 777874839
System.out.println(person2.hashCode());      //prints: 777874839
System.out.println(person3.hashCode());      //prints: 596512129

person1person2引用及其哈希码是相等的,因为它们指向相同的对象(内存的相同区域和相同的地址),而person3引用指向另一个对象。

但实际上,正如我们在第 6 章、“数据结构、泛型和流行工具”中所描述的,我们希望对象的相等性基于所有或部分对象属性的值,因此这里是equals()hashCode()方法的典型实现:

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null) return false;
    if(!(o instanceof Person)) return false;
    Person person = (Person)o;
    return getAge() == person.getAge() &&
            Objects.equals(getFirstName(), person.getFirstName()) &&
            Objects.equals(getLastName(), person.getLastName());
}
@Override
public int hashCode() {
    return Objects.hash(getAge(), getFirstName(), getLastName());
}

它以前更复杂,但是使用java.util.Objects工具会更容易,特别是当您注意到Objects.equals()方法也处理null时。

我们已经将所描述的equals()hashCode()方法的实现添加到Person1类中,并执行了相同的比较:

Person1 person1 = new Person1(42, "Nick", "Samoylov");
Person1 person2 = person1;
Person1 person3 = new Person1(42, "Nick", "Samoylov");
System.out.println(person1.equals(person2)); //prints: true
System.out.println(person1.equals(person3)); //prints: true
System.out.println(person1.hashCode());      //prints: 2115012528
System.out.println(person2.hashCode());      //prints: 2115012528
System.out.println(person3.hashCode());      //prints: 2115012528

如您所见,我们所做的更改不仅使相同的对象相等,而且使具有相同属性值的两个不同对象相等。此外,哈希码值现在也基于相同属性的值。

在第 6 章、“数据结构、泛型和流行工具”中,我们解释了在实现equals()方法的同时实现hasCode()方法的重要性。

equals()方法中建立等式和在hashCode()方法中进行散列计算时,使用完全相同的属性集是非常重要的。

@Override注解放在这些方法前面可以确保它们确实覆盖Object类中的默认实现。否则,方法名中的输入错误可能会造成一种假象,即新的实现被使用了,而实际上它并没有被使用。事实证明,调试这种情况比仅仅添加@Override注解要困难和昂贵得多,如果该方法不覆盖任何内容,就会产生错误。

compareTo()方法

在第 6 章、“数据结构、泛型和流行工具”中,我们广泛使用了compareTo()方法(Comparable接口的唯一方法),并指出基于该方法建立的顺序(通过集合元素实现)称为自然顺序

为了证明这一点,我们创建了Person2类:

public class Person2 implements Comparable<Person2> {
    private int age;
    private String firstName, lastName;
    public Person2(int age, String firstName, String lastName) {
        this.age = age;
        this.lastName = lastName;
        this.firstName = firstName;
    }
    public int getAge() { return age; }
    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    @Override
    public int compareTo(Person2 p) {
        int result = Objects.compare(getFirstName(), 
                     p.getFirstName(), Comparator.naturalOrder());
        if (result != 0) {
            return result;
        }
        result = Objects.compare(getLastName(), 
                      p.getLastName(), Comparator.naturalOrder());
        if (result != 0) {
            return result;
        }
        return Objects.compare(age, p.getAge(), 
                                      Comparator.naturalOrder());
    }
    @Override
    public String toString() {
        return firstName + " " + lastName + ", " + age;
    }
}

然后我们组成一个Person2对象列表并对其进行排序:

Person2 p1 = new Person2(15, "Zoe", "Adams");
Person2 p2 = new Person2(25, "Nick", "Brook");
Person2 p3 = new Person2(42, "Nick", "Samoylov");
Person2 p4 = new Person2(50, "Ada", "Valentino");
Person2 p6 = new Person2(50, "Bob", "Avalon");
Person2 p5 = new Person2(10, "Zoe", "Adams");
List<Person2> list = new ArrayList<>(List.of(p5, p2, p6, p1, p4, p3));
Collections.sort(list);
list.stream().forEach(System.out::println); 

结果如下:

有三件事值得注意:

  • 根据Comparable接口,compareTo()方法必须返回负整数、零或正整数,因为对象小于、等于或大于另一个对象。在我们的实现中,如果两个对象的相同属性的值不同,我们会立即返回结果。我们已经知道这个对象是,不管其他属性是什么。但是比较两个对象的属性的顺序对最终结果有影响。它定义属性值影响顺序的优先级。
  • 我们已将List.of()的结果放入new ArrayList()对象中。我们这样做是因为,正如我们在第 6 章、“数据结构、泛型和流行工具”中已经提到的,工厂方法of()创建的集合是不可修改的。不能在其中添加或删除任何元素,也不能更改元素的顺序,同时需要对创建的集合进行排序。我们使用了of()方法,只是因为它更方便并且提供了更短的表示法
  • 最后,使用java.util.Objects进行属性比较,使得实现比定制编码更简单、更可靠。

在实现compareTo()方法时,重要的是确保不违反以下规则:

  • 只有当返回值为0时,obj1.compareTo(obj2)才返回与obj2.compareTo(obj1)相同的值。
  • 如果返回值不是0,则obj1.compareTo(obj2)obj2.compareTo(obj1)符号相反。
  • 如果obj1.compareTo(obj2) > 0obj2.compareTo(obj3) > 0,那么obj1.compareTo(obj3) > 0
  • 如果obj1.compareTo(obj2) < 0obj2.compareTo(obj3) < 0,那么obj1.compareTo(obj3) < 0
  • obj1.compareTo(obj2) == 0,则obj2.compareTo(obj3)obj1.compareTo(obj3) > 0符号相同。
  • obj1.compareTo(obj2)obj2.compareTo(obj1)抛出相同的异常(如果有的话)。

也建议,但并非总是要求,如果obj1.equals(obj2)那么obj1.compareTo(obj2) == 0,同时,如果obj1.compareTo(obj2) == 0那么obj1.equals(obj2)

clone()方法

java.lang.Object类中的clone()方法实现如下:

@HotSpotIntrinsicCandidate
protected native Object clone() throws CloneNotSupportedException;

注释指出:

/**
 * Creates and returns a copy of this object.  The precise meaning
 * of "copy" may depend on the class of the object.
 ***

此方法的默认结果按原样返回对象字段的副本,如果值是原始类型,则可以这样做。但是,如果对象属性包含对另一个对象的引用,则只复制引用本身,而不复制引用的对象本身。这就是为什么这种拷贝被称为浅拷贝。要获得一个深度副本,必须覆盖clone()方法并克隆引用对象的每个对象属性

在任何情况下,为了能够克隆一个对象,它必须实现Cloneable接口,并确保继承树上的所有对象(以及作为对象的属性)也实现Cloneable接口(除了java.lang.Object类)。Cloneable接口只是一个标记接口,它告诉编译器程序员有意识地决定允许克隆这个对象(无论是因为浅拷贝足够好还是因为clone()方法被覆盖)。试图对未实现Cloneable接口的对象调用clone()将导致CloneNotSupportedException

这看起来已经很复杂了,但实际上,还有更多的陷阱。例如,假设Person类具有Address类类型的address属性。Person对象p1的浅拷贝p2将引用Address的同一对象,因此p1.address == p2.address。下面是一个例子。Address类如下:

class Address {
    private String street, city;
    public Address(String street, String city) {
        this.street = street;
        this.city = city;
    }
    public void setStreet(String street) { this.street = street; }
    public String getStreet() { return street; }
    public String getCity() { return city; }
}

Person3类这样使用它:

class Person3 implements Cloneable{
    private int age;
    private Address address;
    private String firstName, lastName;

    public Person3(int age, String firstName, 
                             String lastName, Address address) {
        this.age = age;
        this.address = address;
        this.lastName = lastName;
        this.firstName = firstName;
    }
    public int getAge() { return age; }
    public Address getAddress() { return address; }
    public String getLastName() { return lastName; }
    public String getFirstName() { return firstName; }
    @Override
    public Person3 clone() throws CloneNotSupportedException{
        return (Person3) super.clone();
    }
}

请注意,方法clone执行浅层复制,因为它不克隆address属性。下面是使用这种方法实现的结果:

Person3 p1 = new Person3(42, "Nick", "Samoylov",
                             new Address("25 Main Street", "Denver"));
Person3 p2 = p1.clone();
System.out.println(p1.getAge() == p2.getAge());                // true
System.out.println(p1.getLastName() == p2.getLastName());      // true
System.out.println(p1.getLastName().equals(p2.getLastName())); // true
System.out.println(p1.getAddress() == p2.getAddress());        // true
System.out.println(p2.getAddress().getStreet());  //prints: 25 Main Street
p1.getAddress().setStreet("42 Dead End");
System.out.println(p2.getAddress().getStreet());  //prints: 42 Dead End

如您所见,克隆完成后,对源对象的address属性所做的更改将反映在克隆的相同属性中。这不是很直观,是吗?克隆的时候我们希望有独立的拷贝,不是吗? 

为了避免共享Address对象,还需要显式地克隆它。为了做到这一点,必须使Address对象可克隆,如下所示:

public class Address implements Cloneable{
    private String street, city;
    public Address(String street, String city) {
        this.street = street;
        this.city = city;
    }
    public void setStreet(String street) { this.street = street; }
    public String getStreet() { return street; }
    public String getCity() { return city; }
    @Override
    public Address clone() throws CloneNotSupportedException {
        return (Address)super.clone();
    }
}

有了这个实现,我们现在可以添加address属性克隆:

class Person4 implements Cloneable{
    private int age;
    private Address address;
    private String firstName, lastName;
    public Person4(int age, String firstName, 
                             String lastName, Address address) {
        this.age = age;
        this.address = address;
        this.lastName = lastName;
        this.firstName = firstName;
    }
    public int getAge() { return age; }
    public Address getAddress() { return address; }
    public String getLastName() { return lastName; }
    public String getFirstName() { return firstName; }
    @Override
    public Person4 clone() throws CloneNotSupportedException{
        Person4 cl = (Person4) super.clone();
 cl.address = this.address.clone();
        return cl;
    }
}

现在,如果我们运行相同的测试,结果将与我们最初预期的一样:

Person4 p1 = new Person4(42, "Nick", "Samoylov",
        new Address("25 Main Street", "Denver"));
Person4 p2 = p1.clone();
System.out.println(p1.getAge() == p2.getAge());                // true
System.out.println(p1.getLastName() == p2.getLastName());      // true
System.out.println(p1.getLastName().equals(p2.getLastName())); // true
System.out.println(p1.getAddress() == p2.getAddress());        // false
System.out.println(p2.getAddress().getStreet()); //prints: 25 Main Street
p1.getAddress().setStreet("42 Dead End");
System.out.println(p2.getAddress().getStreet()); //prints: 25 Main Street

因此,如果应用希望深度复制所有属性,那么所有涉及的对象都必须是可克隆的。只要没有相关的对象,无论是当前对象中的属性还是父类中的属性(以及它们的属性和父对象),在不使它们可克隆的情况下不获取新的对象属性,并且在容器对象的clone()方法中显式克隆,这是可以的。最后一句话很复杂。其复杂性的原因是克隆过程的潜在复杂性。这就是为什么程序员经常远离使对象可克隆的原因。

相反,如果需要,他们更喜欢手动克隆对象。例如:

Person4 p1 = new Person4(42, "Nick", "Samoylov",
                              new Address("25 Main Street", "Denver"));
Address address = new Address(p1.getAddress().getStreet(), 
                                            p1.getAddress().getCity());
Person4 p2 = new Person4(p1.getAge(), p1.getFirstName(), 
                                            p1.getLastName(), address);
System.out.println(p1.getAge() == p2.getAge());                // true
System.out.println(p1.getLastName() == p2.getLastName());      // true
System.out.println(p1.getLastName().equals(p2.getLastName())); // true
System.out.println(p1.getAddress() == p2.getAddress());        // false
System.out.println(p2.getAddress().getStreet()); //prints: 25 Main Street
p1.getAddress().setStreet("42 Dead End");
System.out.println(p2.getAddress().getStreet()); //prints: 25 Main Street

如果向任何相关对象添加了另一个属性,这种方法仍然需要更改代码。但是,它提供了对结果的更多控制,并且发生意外后果的可能性更小。

幸运的是,clone()方法并不经常使用。事实上,你可能永远不会遇到使用它的需要。

StringBufferStringBuilder

我们在第 6 章、“数据结构、泛型和流行工具”中讨论了StringBufferStringBuilder类之间的区别。我们这里不重复了。相反,我们只会提到,在单线程进程(这是绝大多数情况下)中,StringBuilder类是首选,因为它更快。

try-catch-finally

本书包含第 4 章、“处理”,专门介绍trycatchfinally子句的用法,这里不再赘述。我们只想再次重申,使用资源尝试语句是释放资源的首选方法(传统上是在finally块中完成的)。遵从库使代码更简单、更可靠。

最佳设计实践

术语最佳通常是主观的和上下文相关的。这就是为什么我们要披露以下建议是基于主流节目中的绝大多数案例。但是,不应盲目和无条件地遵循这些原则,因为在某些情况下,有些做法是无用的,甚至是错误的。在跟随他们之前,试着理解他们背后的动机,并将其作为你的决策指南。例如,大小很重要。如果应用不会超过几千行代码,那么一个简单的带有洗衣单样式代码的整体就足够了。但是,如果有复杂的代码包和几个人在处理它,如果一个特定的代码区域需要比其他区域更多的资源,那么将代码分解成专门的片段将有利于代码理解、维护甚至扩展。

我们将从没有特定顺序的更高层次的设计决策开始。

确定松散耦合的功能区域

这些设计决策可以很早就做出,仅仅是基于对未来系统的主要部分、它们的功能以及它们产生和交换的数据的一般理解。这样做有几个好处:

  • 对未来系统结构的识别,对进一步的设计步骤和实现有影响
  • 部件的专业化和深入分析
  • 部件并行开发
  • 更好地理解数据流

将功能区划分为传统层

在每个功能区就绪后,可以根据所使用的技术方面和技术进行特化。技术专业化的传统分离是:

  • 前端(用户图形或 Web 界面)
  • 具有广泛业务逻辑的中间层
  • 后端(数据存储或数据源)

这样做的好处包括:

  • 按层部署和扩展
  • 基于专业知识的程序员专业化
  • 部件并行开发

面向接口编程

基于前两小节中描述的决策的专用部分必须在隐藏实现细节的接口中描述。这种设计的好处在于面向对象编程的基础,在第 2 章、“Java 面向对象编程(OOP)”中有详细的描述,所以这里不再重复。

使用工厂

我们在第二章“Java 面向对象编程(OOP)”中也谈到了这一点。根据定义,接口不描述也不能描述实现接口的类的构造器。使用工厂可以缩小这个差距,只向客户端公开一个接口

优先组合而不是继承

最初,面向对象编程的重点是继承,作为在对象之间共享公共功能的方式。继承甚至是我们在第 2 章、“Java 面向对象编程(OOP)”中所描述的四个面向对象编程原则之一。然而,实际上,这种功能共享方法在同一继承行中包含的类之间创建了太多的依赖关系。应用功能的演化通常是不可预测的,继承链中的一些类开始获取与类链的原始目的无关的功能。我们可以说,有一些设计解决方案允许我们不这样做,并保持原始类完好无损。但是,在实践中,这样的事情总是发生,子类可能会突然改变行为,仅仅因为它们通过继承获得了新的功能。我们不能选择我们的父项,对吗?此外,封装方式是封装的另一个基础原则。

另一方面,组合允许我们选择和控制类的哪些功能可以使用,哪些可以忽略。它还允许对象保持轻,而不受继承的负担。这样的设计更灵活、更可扩展、更可预测。

使用库

在整本书中,我们多次提到使用 Java 类库JCL)、Java 开发工具包JDK)和外部 Java 库可以使编程变得更简单,并生成更高质量的代码。甚至还有一个专门的章节,第 7 章、“Java 标准和外部库”,其中概述了最流行的 Java 库。创建库的人会投入大量的时间和精力,所以你应该随时利用他们。

在第 13 章、“函数式编程”中,我们描述了驻留在 JCL 的java.util.function包中的标准函数式接口。这是另一种利用库的方法,使用一组众所周知的共享接口,而不是定义自己的接口。

这最后一句话是本章下一个主题的一个很好的过渡,这个主题是关于编写其他人容易理解的代码。

代码是为人编写的

最初几十年的编程需要编写机器命令,以便电子设备能够执行这些命令。这不仅是一项繁琐且容易出错的工作,而且还要求您以产生最佳性能的方式编写指令,因为计算机速度很慢,而且根本没有进行太多代码优化。

从那时起,我们在硬件和编程方面都取得了很大的进步。现代编译器在使提交的代码尽可能快地工作方面走了很长的路,即使程序员没有考虑它。我们在上一章第 1 章第 7 章“Java 微基准线束”中用具体的例子进行了讨论

它允许程序员编写更多的代码行,而不用考虑太多优化问题。但是传统和许多关于编程的书籍仍然需要它,一些程序员仍然担心他们的代码性能,而不是它产生的结果。遵循传统比脱离传统容易。这就是为什么程序员往往更关注他们编写代码的方式,而不是他们自动化的业务,尽管实现错误业务逻辑的好代码是无用的。

不过,回到话题上来。在现代 JVM 中,程序员对代码优化的需求不像以前那么迫切了。如今,程序员必须主要关注全局,以避免导致代码性能差和代码被多次使用的结构性错误。当 JVM 变得更复杂时,后者就变得不那么紧迫了,实时地观察代码,当用相同的输入多次调用相同的代码块时,只返回结果(不执行)。

这给我们留下了唯一可能的结论:在编写代码时,我们必须确保它对人类来说是容易阅读和理解的,而不是对计算机来说。那些在这个行业工作了一段时间的人对几年前自己编写的代码感到困惑。一种是通过清晰和透明的意图来改进代码编写风格。

我们可以讨论注释的必要性,直到奶牛回到谷仓。我们绝对不需要注释来直接响应代码的功能。例如:

//Initialize variable
int i = 0;

解释意图的注释更有价值:

// In case of x > 0, we are looking for a matching group 
// because we would like to reuse the data from the account.
// If we find the matching group, we either cancel it and clone,
// or add the x value to the group, or bail out.
// If we do not find the matching group,
// we create a new group using data of the matched group.

注释代码可能非常复杂。好的注释解释了意图并提供了帮助我们理解代码的指导。然而,程序员通常不会费心去写注释。反对写注释的论据通常包括两种:

  • 注释必须与代码一起维护和发展,否则,它们可能会产生误导,但是没有工具可以提示程序员在更改代码的同时调整注释。因此,注释是危险的。
  • 代码本身的编写(包括变量和方法的名称选择)不需要额外的解释。

这两种说法都是正确的,但注释也确实非常有用,尤其是那些抓住意图的注释。此外,这样的注释往往需要较少的调整,因为代码意图不会经常更改(如果有的话)。

测试是获得高质量代码的最短路径

我们将讨论的最后一个最佳实践是这样的陈述:测试不是一项开销或一项负担;它是程序员成功的指南。唯一的问题是什么时候写测试

有一个令人信服的论点,要求在编写任何一行代码之前编写一个测试。如果你能做到,那就太好了。我们不会劝你放弃的。但是,如果您不这样做,请尝试在编写完一行或所有被指定编写的代码之后开始编写测试。

实际上,许多经验丰富的程序员发现在实现了一些新功能之后开始编写测试代码是很有帮助的,因为这是程序员更好地理解新代码如何适合现有上下文的时候。他们甚至可能尝试对一些值进行编码,以查看新代码与调用新方法的代码集成的程度。在确保新代码集成良好之后,程序员可以继续实现和调优新的代码,并根据调用代码上下文中的需求测试新实现。

必须添加一个重要的限定条件:在编写测试时,最好不是由您来设置输入数据和测试标准,而是由分配给您任务的人或测试人员来设置。根据代码生成的结果设置测试是众所周知的程序员陷阱。客观的自我评估并不容易,如果可能的话

总结

在本章中,我们讨论了主流程序员每天遇到的 Java 习惯用法。我们还讨论了最佳设计实践和相关建议,包括代码编写风格和测试。

在本章中,读者了解了与某些特性、功能和设计解决方案相关的最流行的 Java 习惯用法。这些习语通过实际例子进行了演示,读者已经学会了如何将它们融入到自己的代码中,以及如何使用专业语言与其他程序员进行交流

在下一章中,我们将向读者介绍为 Java 添加新特性的四个项目:Panama、Valhalla、Amber 和 Loom。我们希望它能帮助读者了解 Java 开发,并设想未来版本的路线图。

测验

  1. 选择所有正确的语句:

    1. 习语可以用来传达代码意图。
    2. 习语可以用来解释代码的作用。
    3. 习语可能被误用,使谈话的主题模糊不清。
    4. 为了表达清楚,应该避免使用习语。
  2. 是否每次执行equals()时都需要执行hasCode()

  3. 如果obj1.compareTo(obj2)返回负值,这是什么意思?

  4. 深度复制概念是否适用于克隆期间的原始类型值?

  5. 哪个更快,StringBuffer还是StringBuilder

  6. 面向接口编程有什么好处?

  7. 使用组合和继承有什么好处?

  8. 与编写自己的代码相比,使用库的优势是什么?

  9. 你的代码的目标受众是谁?

  10. 是否需要测试?