Java 中隐藏的 5 个秘密

随着编程语言的发展,隐藏的功能不可避免地开始出现,而创始人从未打算使用的结构开始逐渐流行起来。其中一些功能以习语的形式出现并成为语言中公认的用语,而另一些则成为反模式并被归入语言社区的黑暗角落。在本文中,我们将了解五个经常被大量 Java 开发人员(有些人有充分理由)忽视的 Java 秘密。对于每个描述,我们将查看使每个功能存在的用例和基本原理,并查看一些可能适合使用这些功能的示例。

读者应该注意到,并非所有这些特性都没有真正隐藏在语言中,而是在日常编程中经常不用。虽然有些在适当的时候可能非常有用,但其他的几乎总是一个糟糕的主意,在本文中展示它们是为了激发读者的兴趣(并可能让他或她开怀大笑)。读者在决定何时使用本文中描述的功能时应根据自己的判断:仅仅因为可以做到并不意味着就应该这样做。

1.注解实现

自 Java Development Kit (JDK) 5 以来,注释已成为许多 Java 应用程序和框架的组成部分。在绝大多数情况下,注解应用于语言结构,例如类、字段、方法等,但还有另一种情况可以应用注解:作为可实现的接口。例如,假设我们有以下注解定义:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
    String name();
}

通常,我们会将此注解应用于方法,如下所示:

ublic class MyTestFixure {

    @Test
    public void givenFooWhenBarThenBaz() {
        // ...
    }
}

然后我们可以处理这个注释,如在 Java 中创建注释中所述。如果我们还想创建一个允许将测试创建为对象的接口,我们将不得不创建一个新接口,将其命名为 Test:

public interface TestInstance {
    public String getName();
}

然后我们可以实例化一个 TestInstance 对象:

public class FooTestInstance implements TestInstance {

    @Override
    public String getName() {
        return "Foo";
    }
}

TestInstance myTest = new FooTestInstance();

虽然我们的注解和界面几乎完全相同,并且有非常明显的重复,但似乎没有办法合并这两个结构。幸运的是,外表是骗人的,有一种技术可以合并这两个结构:实现注解:

p

ublic class FooTest implements Test {

    @Override
    public String name() {
        return "Foo";
    }

    @Override
    public Class<? extends Annotation> annotationType() {
        return Test.class;
    }

请注意,我们必须实现该 annotationType 方法并返回注释的类型,因为这是 Annotation 接口的隐含部分。尽管在几乎所有情况下,实现注解都不是一个合理的设计决策(Java 编译器在实现接口时会显示警告),但它在少数情况下很有用,例如在注解驱动的框架中。

2.实例初始化

在 Java 中,与大多数面向对象的编程语言一样,对象是专门使用构造函数实例化的(有一些关键的例外,例如 Java 对象反序列化)。即使当我们创建静态工厂方法来创建对象时,我们也只是包装了对对象构造函数的调用以实例化它。例如:

public class Foo {

   private final String name;

   private Foo(String name) {
       this.name = name;
   }

   public static Foo withName(String name) {
       return new Foo(name);
   }
}

Foo foo = Foo.withName("Bar");

因此,当我们希望初始化一个对象时,我们将初始化逻辑合并到对象的构造函数中。例如,我们 在其参数化构造函数name 中设置类的字段 。 虽然所有 初始化逻辑都可以在类的构造函数或构造函数集中找到,Foo这似乎是一个合理的假设,但在 Java 中并非如此。相反,我们还可以使用实例初始化来在创建对象时执行代码:

public class Foo {

    {
        System.out.println("Foo:instance 1");
    }

    public Foo() {
        System.out.println("Foo:constructor");
    }
}

实例初始值设定项是通过在类定义中的一组花括号内添加初始化逻辑来指定的。当对象被实例化时,它的实例初始化器首先被调用,然后是它的构造函数。请注意,可以指定多个实例初始值设定项,在这种情况下,每个实例初始值设定项都按照它在类定义中出现的顺序被调用。除了实例初始化器,我们还可以创建静态初始化器,它们在类加载到内存时执行。要创建静态初始化器,我们只需在初始化器前加上关键字 static:

public class Foo {

    {
        System.out.println("Foo:instance 1");
    }

    static {
        System.out.println("Foo:static 1");
    }

    public Foo() {
        System.out.println("Foo:constructor");
    }
}

当所有三种初始化技术(构造函数、实例初始化器和静态初始化器)都出现在一个类中时,静态初始化器总是按照声明的顺序首先执行(当类加载到内存中时),然后是实例初始化器的顺序它们被声明,最后由构造函数声明。当引入超类时,执行顺序略有变化:

超类的静态初始值设定项,按照它们声明的顺序
子类的静态初始值设定项,按照它们声明的顺序
超类的实例初始值设定项,按照它们声明的顺序
超类的构造函数
子类的实例初始化器,按照它们声明的顺序
子类的构造器
例如,我们可以创建以下应用程序:

public abstract class Bar {

    private String name;

    static {
        System.out.println("Bar:static 1");
    }

    {
        System.out.println("Bar:instance 1");
    }

    static {
        System.out.println("Bar:static 2");
    }

    public Bar() {
        System.out.println("Bar:constructor");
    }

    {
        System.out.println("Bar:instance 2");
    }

    public Bar(String name) {
        this.name = name;
        System.out.println("Bar:name-constructor");
    }
}

public class Foo extends Bar {

    static {
        System.out.println("Foo:static 1");
    }

    {
        System.out.println("Foo:instance 1");
    }

    static {
        System.out.println("Foo:static 2");
    }

    public Foo() {
        System.out.println("Foo:constructor");
    }

    public Foo(String name) {
        super(name);
        System.out.println("Foo:name-constructor");
    }

    {
        System.out.println("Foo:instance 2");
    }

    public static void main(String... args) {
        new Foo();
        System.out.println();
        new Foo("Baz");
    }
}

如果我们执行这段代码,我们会收到以下输出:

Bar:static 1
Bar:static 2
Foo:static 1
Foo:static 2
Bar:instance 1
Bar:instance 2
Bar:constructor
Foo:instance 1
Foo:instance 2
Foo:constructor

Bar:instance 1
Bar:instance 2
Bar:name-constructor
Foo:instance 1
Foo:instance 2
Foo:name-constructor

请注意,静态初始值设定项仅执行一次,即使 Foo 创建了两个对象。虽然实例和静态初始化器可能很有用,但初始化逻辑应该放在构造函数中,当需要复杂的逻辑来初始化对象的状态时,应该使用方法(或静态方法)。

3.双括号初始化

许多编程语言都包含一些句法机制,无需使用冗长的样板代码即可快速简洁地创建列表或地图(或字典)。例如,C++ 包括大括号初始化,允许开发人员快速创建枚举值列表,或者如果对象的构造函数支持此功能,甚至可以初始化整个对象。不幸的是,在 JDK 9 之前,没有包含这样的功能(我们将很快谈到这个包含)。为了天真地创建对象列表,我们将执行以下操作:

List<Integer> myInts = new ArrayList<>();
myInts.add(1);
myInts.add(2);
myInts.add(3);

虽然这实现了我们创建一个用三个值初始化的新列表的目标,但它过于冗长,需要开发人员为每次添加重复列表变量的名称。为了缩短这段代码,我们可以使用双括号初始化来添加相同的三个元素:

List<Integer> myInts = new ArrayList<>() {{
    add(1);
    add(2);
    add(3);
}};

双大括号初始化——它的名字来源于两个开大括号和闭大括号的集合——实际上是多个句法元素的组合。首先,我们创建一个扩展 该类的匿名内部ArrayList 类。由于 ArrayList 没有抽象方法,我们可以为匿名实现创建一个空主体:

List<Integer> myInts = new ArrayList<>() {};

使用这段代码,我们实质上创建了一个 ArrayList 与原始 ArrayList. 主要区别之一是我们的内部类具有对包含类的隐式引用(以捕获 this 变量的形式),因为我们正在创建一个非静态内部类。这允许我们编写一些有趣的——如果不是令人费解的话——逻辑,例如将捕获的 this 变量添加到匿名的、双括号初始化的内部类中:

public class Foo {

    public List<Foo> getListWithMeIncluded() {
        return new ArrayList<Foo>() {{
            add(Foo.this);
        }};
    }

    public static void main(String... args) {
        Foo foo = new Foo();
        List<Foo> fooList = foo.getListWithMeIncluded();
        System.out.println(foo.equals(fooList.get(0)));
    }
}

如果这个内部类是静态定义的,我们将无法访问 Foo.this. 例如,以下静态创建命名 FooArrayList 内部类的代码无法访问 Foo.this 引用,因此不可编译:

public class Foo {

    public List<Foo> getListWithMeIncluded() {
        return new FooArrayList();
    }

    private static class FooArrayList extends ArrayList<Foo> {{
        add(Foo.this);
    }}
}

继续构造我们的双括号 initialized ArrayList,一旦我们创建了非静态内部类,我们就使用实例初始化,正如我们上面看到的,在匿名内部类被实例化时执行三个初始元素的加法。由于匿名内部类是立即实例化的,并且匿名内部类的对象永远只存在一个,因此我们实质上创建了一个非静态内部单例对象,它在创建时添加了三个初始元素。如果我们将一对大括号分开,这可以变得更加明显,其中一个大括号明确构成了匿名内部类的定义,另一个大括号表示实例初始化逻辑的开始:

List<Integer> myInts = new ArrayList<>() {
    {
        add(1);
        add(2);
        add(3);
    }
};

虽然这个技巧很有用,但 JDK 9 ( JEP 269 ) 已经用一组静态工厂方法 List (以及许多其他集合类型)取代了这个技巧的实用性。例如,我们可以 List 使用这些静态工厂方法创建上面的内容,如以下清单所示:

List<Integer> myInts = List.of(1, 2, 3);

这种静态工厂技术之所以受欢迎,主要有两个原因:(1) 不创建匿名内部类,(2) 减少创建 List. List 以这种方式 创建 a 的注意事项 是结果List 是不可变的,因此一旦创建就不能修改。为了创建 List 具有所需初始元素的可变变量,我们不得不使用朴素技术或双括号初始化。

请注意,原始初始化、双括号初始化和 JDK 9 静态工厂方法不仅适用于List. 它们也可用于 Set 和 Map 对象,如以下代码片段所示:

// Naive initialization
Map<String, Integer> myMap = new HashMap<>();
myMap.put("Foo", 10);
myMap.put("Bar", 15);

// Double-brace initialization
Map<String, Integer> myMap = new HashMap<>() {{
    put("Foo", 10);
    put("Bar", 15);
}};

// Static factory initialization
Map<String, Integer> myMap = Map.of("Foo", 10, "Bar", 15);

在决定使用双括号初始化之前考虑它的性质很重要。虽然它确实提高了代码的可读性,但它带有一些隐含的副作用。

4. 可执行注释

注释几乎是每个程序的重要组成部分,注释的主要好处是它们不会被执行。当我们在程序中注释掉一行代码时,这一点变得更加明显:我们希望在应用程序中保留代码,但我们不希望它被执行。例如,以下程序会 5 打印到标准输出:

public static void main(String args[]) {
    int value = 5;
    // value = 8;
    System.out.println(value);
}

虽然评论永远不会被执行是一个基本假设,但这并不完全正确。例如,以下代码片段打印到标准输出的内容是什么?

public static void main(String args[]) {
    int value = 5;
    // \u000dvalue = 8;
    System.out.println(value);
}

一个很好的猜测是 5 ,但是如果我们运行上面的代码,我们会看到 8 打印到标准输出。这个看似错误背后的原因是 Unicode 字符\u000d;这个字符实际上是一个Unicode 回车符,编译器将 Java 源代码作为 Unicode 格式的文本文件使用。添加此回车会将赋值推value = 8到紧跟注释的行,确保它被执行。这意味着上面的代码片段实际上等于以下内容:

public static void main(String args[]) {
    int value = 5;
    // 
value = 8;
    System.out.println(value);
}

虽然这看起来是 Java 中的一个错误,但它实际上是语言中有意识的包含。Java 的最初目标是创建一种独立于平台的语言(因此创建了 Java 虚拟机或 JVM),源代码的互操作性是该目标的一个关键方面。通过允许 Java 源代码包含 Unicode 字符,我们可以以通用方式包含非拉丁字符。这确保了在世界一个地区编写的代码(可能包含非拉丁字符,例如在注释中)可以在任何其他地区执行。有关详细信息,请参阅Java 语言规范或 JLS 的第 3.3 节。

我们可以将其发挥到极致,甚至可以用 Unicode 编写整个应用程序。例如,下面的程序是做什么的(源代码来自Java:Executing code in comments?!)?

\u0070\u0075\u0062\u006c\u0069\u0063\u0020\u0020\u0020\u0020
\u0063\u006c\u0061\u0073\u0073\u0020\u0055\u0067\u006c\u0079
\u007b\u0070\u0075\u0062\u006c\u0069\u0063\u0020\u0020\u0020
\u0020\u0020\u0020\u0020\u0073\u0074\u0061\u0074\u0069\u0063
\u0076\u006f\u0069\u0064\u0020\u006d\u0061\u0069\u006e\u0028
\u0053\u0074\u0072\u0069\u006e\u0067\u005b\u005d\u0020\u0020
\u0020\u0020\u0020\u0020\u0061\u0072\u0067\u0073\u0029\u007b
\u0053\u0079\u0073\u0074\u0065\u006d\u002e\u006f\u0075\u0074
\u002e\u0070\u0072\u0069\u006e\u0074\u006c\u006e\u0028\u0020
\u0022\u0048\u0065\u006c\u006c\u006f\u0020\u0077\u0022\u002b
\u0022\u006f\u0072\u006c\u0064\u0022\u0029\u003b\u007d\u007d

如果将上面的代码放在名为 Ugly.java 的文件中并执行,它会打印 Hello world 到标准输出。如果我们将这些转义的 Unicode 字符转换为 美国信息交换标准代码 (ASCII)字符,我们将获得以下程序:

public    
class Ugly
{public   
    static
void main(
String[]  
    args){
System.out
.println( 
"Hello w"+
"orld");}}

尽管了解 Unicode 字符可以包含在 Java 源代码中很重要,但强烈建议除非需要(例如,在注释中包含非拉丁字符),否则应避免使用它们。如果需要它们,请确保不要包含会更改源代码预期行为的字符,例如回车。

5.枚举接口实现

与 Java 中的类相比,枚举(枚举)的局限性之一是枚举不能扩展另一个类或枚举。例如,无法 执行以下操作:

public class Speaker {

    public void speak() {
        System.out.println("Hi");
    }
}

public enum Person extends Speaker {

    JOE("Joseph"),
    JIM("James");

    private final String name;

    private Person(String name) {
        this.name = name;
    }
}

Person.JOE.speak();

然而,我们可以让我们的枚举实现一个接口并为其抽象方法提供一个实现,如下所示:

public interface Speaker {
    public void speak();
}

public enum Person implements Speaker {

    JOE("Joseph"),
    JIM("James");

    private final String name;

    private Person(String name) {
        this.name = name;
    }

    @Override
    public void speak() {
        System.out.println("Hi");
    }
}

Person.JOE.speak();

我们现在还可以在 任何需要 对象的Person 地方 使用实例。Speaker此外,我们还可以在每个常量的基础上提供接口抽象方法的实现(称为特定于常量的方法):

public interface Speaker {
    public void speak();
}

public enum Person implements Speaker {

    JOE("Joseph") {
        public void speak() { System.out.println("Hi, my name is Joseph"); }
    },
    JIM("James"){
        public void speak() { System.out.println("Hey, what's up?"); }
    };

    private final String name;

    private Person(String name) {
        this.name = name;
    }

    @Override
    public void speak() {
        System.out.println("Hi");
    }
}

Person.JOE.speak();

与本文中的其他一些秘诀不同,这种技术应该在适当的地方得到鼓励。例如,如果可以使用枚举常量(例如 JOE 或 JIM)代替接口类型(例如 )Speaker,则定义常量的枚举应该实现接口类型。有关详细信息,请参阅Effective Java,第 3 版的第 38 项(第 176-9 页)。

结论
在本文中,我们了解了 Java 中的五个隐藏秘密,即:(1) 可以扩展注解,(2) 实例初始化可以用于在实例化时配置对象,(3) 双括号初始化可以用于执行创建匿名内部类时的指令,(4)有时可以执行注释,(5)枚举可以实现接口。虽然其中一些功能有其适当的用途,但应避免使用其中一些功能(即创建可执行注释)。在决定使用这些秘密时,一定要遵守以下规则:仅仅因为某事可以做,并不意味着它应该做。

你可能感兴趣的:(java,spring,jvm)