《Effective Java》可能在哪些方面影响了 Kotlin 的设计?

原文:How “Effective Java” may have influenced the design of Kotlin

翻译:技术风向标

来源:程序师网 ,标题有改动

Java是伟大的编程语言无疑,但它也有一些众所周知的缺陷,比如那些常见的坑和从早期继承下来的不太重要的东西(Java 1.0发布于1995年)。 Joshua Bloch写了一本颇受推崇的书叫《Effective Java》,内容是关于如何写出好的Java代码,同时避免常见的编码错误及如何应对Java的不足。它有78个章节,称为“条目”,从多个方面为读者提供关于Java编程的宝贵建议。

现代编程语言的创造者有很大的优势,因为他们能够分析现有语言的缺点,并在设计语言的时候尽量避免。Jetbrains是一家开发了几款非常受欢迎的IDE的公司,于2010年决定为自己的开发工作创造一种编程语言——Kotlin。它的目标是更简洁、更有表现力,同时避免Java的一些不足。这家公司之前发布的所有IDE都是用Java编写的,所以他们需要一种与Java高度互操作的语言,并能够编译成Java字节码。他们还希望Java开发人员可以轻松切换到Kotlin. 也就是说,Jetbrains希望构建一个更好的Java。

1-z8D9LL3fGVfMZ2VrKGRiBQ.png

在重读《Effective Java》时,我发现其中的很多内容对Kotlin来说已经用不着了,所以产生了一个想法,想探讨一下这本书是否影响了Kotlin的设计。

1. Kotlin可以有默认参数,不再需要builder

当Java构造函数有很多可选参数时,代码将变得冗长,可读性差且容易出错。针对这个问题,Effective Java的条目2讲述了如何有效地使用构造器模式(Builder Pattern)。构建这样的对象需要写很多代码,如下面的代码示例中的“营养学”对象。它有两个必需的参数(serveSize,servings)和四个可选参数(calories, fat, sodium, carbohydrates):

public class JavaNutritionFacts {
    private final int servingSize;
    private final int servings;
    private final int calories;
    private final int fat;
    private final int sodium;
    private final int carbohydrate;
    public static class Builder {
        // Required parameters
        private final int servingSize;
        private final int servings;
        // Optional parameters - initialized to default values
        private int calories      = 0;
        private int fat           = 0;
        private int carbohydrate  = 0;
        private int sodium        = 0;
        public Builder(int servingSize, int servings) {
            this.servingSize = servingSize;
            this.servings    = servings;
        }
        public Builder calories(int val)
        { calories = val;      return this; }
        public Builder fat(int val)
        { fat = val;           return this; }
        public Builder carbohydrate(int val)
        { carbohydrate = val;  return this; }
        public Builder sodium(int val)
        { sodium = val;        return this; }
        public JavaNutritionFacts build() {
            return new JavaNutritionFacts(this);
        }
    }
    private JavaNutritionFacts(Builder builder) {
        servingSize  = builder.servingSize;
        servings     = builder.servings;
        calories     = builder.calories;
        fat          = builder.fat;
        sodium       = builder.sodium;
        carbohydrate = builder.carbohydrate;
    }
}

要用Java实例化一个对象,就得这样:

final JavaNutritionFacts cocaCola = new JavaNutritionFacts.Builder(240,8)
    .calories(100)
    .sodium(35)
    .carbohydrate(27)
    .build();

而在Kotlin中,你不再需要使用构造器模式,因为它有默认参数的功能,允许你为每个可选的构造函数参数定义默认值:

class KotlinNutritionFacts(
        private val servingSize: Int,
        private val servings: Int,
        private val calories: Int = 0,
        private val fat: Int = 0,
        private val sodium: Int = 0,
        private val carbohydrates: Int = 0)

所以在Kotlin中创建对象就可以这样:

val cocaCola = KotlinNutritionFacts(240,8,
                calories = 100,
                sodium = 35,
                carbohydrates = 27)

如果想让可读性更强,你也可以把必需的参数命名为 servingSize 和 servings

val cocaCola = KotlinNutritionFacts(
                servingSize = 240,
                servings = 8,
                calories = 100,
                sodium = 35,
                carbohydrates = 27)

跟Java一样,这里创建的对象是不可变的。

我们将Java的47行代码减少到了Kotlin的7行,大大提高了生产力。

温馨提示:如果想用Java创建这样的 KotlinNutrition 对象当然也是可以做到的,但你得为每个可选参数设定一个值。还好,只要加上 JvmOverloads 注解,那么就会自动生成多个构造器,使用注解时需要 constructor关键字:

class KotlinNutritionFacts @JvmOverloads constructor(
        private val servingSize: Int,
        private val servings: Int,
        private val calories: Int = 0,
        private val fat: Int = 0,
        private val sodium: Int = 0,
        private val carbohydrates: Int = 0)

2. 创建单例(singleton)很容易

Effective Java 的条目3说了如何设计一个单例Java对象,也就是只能实例化一个实例的对象。下面的代码片段展示了一个“单向”的宇宙,其中只能存在一个猫王:

public class JavaElvis {
    private static JavaElvis instance;
    private JavaElvis() {}
    public static JavaElvis getInstance() {
        if (instance == null) {
            instance = new JavaElvis();
        }
        return instance;
    }
    public void leaveTheBuilding() {
    }
}

Kotlin 有“对象声明”的概念,可以方便的通过对象声明来获得一个单例。

object KotlinElvis {
    fun leaveTheBuilding() {}
}

再也不用费劲构造单例了。

3. 开箱即用的 equals() 和 hashCode()

良好的编程实践起源于功能编程,简化代码主要靠使用不可变值对象。条目15建议“类应该是不可变的,除非有足够的理由将它们设为可变”。创建不可变的值对象在Java中非常繁琐,因为你必须为每个对象重写equals()和hashCode()。Joshua Bloch在条目8和9用了足足18页描述了关于这两种方法的准则。例如,如果你重写equals(),你必须保证自反性、对称性、传递性、一致性和无效性,听起来不像在编程而更像数学。

在Kotlin中,这种情况下你可以直接使用数据类,编译器会自动导出equals(),hashCode()等方法。这是因为标准方法可以从对象的属性中直接派生出来,只需在类前面输入关键字数据即可,完全不需要18页的描述。

提示:最近,Java的AutoValue很流行,该库可为Java 1.6+生成不可变值类。

4. 属性(properties)取代域(fields)

public class JavaPerson {
    // don't use public fields in public classes!
    public String name;
    public Integer age;
}

条目14建议在公有类中使用访问器方法而不是公有字段。如果您不这么做的话可能会遇到麻烦,因为域可以直接访问,导致完全享受不到封装好处。这意味着日后你将无法在不改动其公共API的情况下更改该类的内部表达。比如,后面你就不能再去限制某个字段的值,例如人的年龄。这就是为什么我们总是在Java中创建这些冗长的默认getter和setter的原因之一。

而Kotlin直接用自动生成默认getter和setter的属性取代了字段/域。

class KotlinPerson {
    var name: String? = null
    var age: Int? = null
}

从语法上来说,你可以使用person.name 或者 person.age访问Java中的公共字段等属性。之后也可以添加自定义的getter和setter而无需更改类的API:

class KotlinPerson {
    var name: String? = null
    var age: Int? = null
    set(value) {
        if (value in 0..120){
            field = value
        } else{
            throw IllegalArgumentException()
        }
    }
}

长话短说:有了Kotlin的属性,我们的类将更简洁,同时还具有与生俱来的灵活性。

5. override成为强制关键字而不是可选注解

Java 1.5 中加入了注解(annotation),其中最重要的一个是重写(override),表示这个方法是对超类中该方法的重写。基于书中条目36,应该尽量使用这个可选注解以避免一些恶心的bug。比如当你以为你重写了超类的方法但其实并没有时,编译器会抛出一个错误。不过如果你记得加上了override注解的话就没事。

在Kotlin中,override不是可选的注解而是强制关键字。所以由此引发的bug就不会再有了,编译器会提前警告你。

6. 默认的 final 类

《Effective Java》在第17条说,**要么为继承而设计,并提供文档说明,要么就禁止继承。**在Java中,除非将类显式指定为final,否则每个类都可以被继承。如果你忘记把类指定为final,也没有好好为继承而设计,那么当客户创建子类并覆盖某些方法时,很可能功能会出问题。

在Kotlin,所有类默认都是final的。如果要允许继承,则必须明确使用关键字open,这与Java的final完全相反。这样可以避免创建并非有意设计继承的非final类。

Kotlin社区有人对这个 “默认的final” 设计很不满。Kotlin论坛对此进行了激烈的讨论。后来,在Kotlin 1.1 beta版中提供了一个编译器插件,可以让class默认是open.

7. 没有检查型异常(checked exceptions)

Java有一个广为人知的特性,即检查型异常,编译器会强制函数的调用者捕获(或重新抛出)异常,这个功能总是容易出问题。 《Effective Java》花了一整个章节来阐述如何正确的使用和处理检查型和非检查型(即运行时)异常。

如Kotlin的文档中所述,检查型异常的一个问题是你有时必须捕获永远不会发生的异常,这将导致空的catch块和冗余代码。而且开发人员经常在被迫处理异常感到麻烦而直接忽略它们,这也会导致空的catch块。第65项说“不要忽视异常”。

根据第59条,检查型异常往往是不必要的,而且这应该通过在调用对象之前检查对象的状态,或者通过判断返回值(比如null)来避免。

我还发现了检查型异常的其它问题:

  • throws子句把实现细节加入接口,这种做法不好;

  • 版本化可能有问题。如果你修改了类的实现并向函数中添加了一个throws子句,那么API就发生了变化;

  • 调用函数不应该规定调用者如何处理异常;

由于存在大量潜在问题,Kotlin等优秀编程语言(如C#)没有检查型异常。为了让调用者知道可能发生的异常,应该用 throws 标签在函数的文档中定义它们。

8. 强制的 null 检查

在Java中,public方法的方法签名不会告诉你返回值是否为空。例如:

public List<Item> getSelectedItems()

如果一条都没选会怎么样?这个方法是否返回null?还是返回空列表?如果不看方法的实现细节,我们就无法知道(除非这个方法有很好的javadoc描述了返回类型)。

这种情况下,开发人员可能会犯的两个错误是:

1.忘记检查返回值是否为空,导致著名的NullPointerException;

2.在返回值永远不可能为空的情况下检查了其是否为空,造成代码冗余。

Joshua Bloch 在第43条建议,用返回一个空的集合数组来取代返回null。这一条让我想到了可空和不可空类型。有了Kotlin的空安全性(null safety),你将知道返回值是否为空。

举个例子:一个返回类型List ? 意味着它可以为null,而List 则表示不能为null。如果它可以为空,编译器就会强制我们在访问其属性或调用其函数之前检查它是否为null。所以,更为强大的编译器将阻止开发者犯错误,生活突然变得容易了。

9. 没有原始类型(Raw types)

Java 1.5中引入了泛型,它们是实现类型安全的好方法。而为了向后兼容性,仍然可以使用原始类型,但Joshua Bloch在第23条中建议使用泛型(List 而不是List)以避免ClassCastExceptions。Kotlin不允许使用原始类型,因此必须为泛型指定类型参数,从而实现代码的类型安全。

总结

以上就是我认为《Effective Java》这本书影响了 Kotlin 设计的几处地方,肯定有遗漏,如果你有其它意见和建议,欢迎讨论。