你肯定不知道的Java中的5个隐藏秘密!
你想成为一名Java大师吗?揭示Java的古老秘密。我们将专注于扩展注释,初始化,注释和枚举接口。

随着编程语言的发展,不可避免地会出现隐藏功能,而创始人从未想过的构造开始逐渐普及。这些功能中的一些功能成为习惯用法,并成为语言的公认用语,而其他功能则成为反模式,并降级到语言社区的黑暗角落。在本文中,优锐课小U将带大家研究五个Java秘密,这些秘密通常被大量Java开发人员忽略(有些理由很充分)。通过每个描述,我们将研究使每个功能都存在的用例和基本原理,并查看一些适合使用这些功能的示例。
你应注意,并非所有这些功能并未真正隐藏在语言中,而是经常在日常编程中未使用。尽管有些在适当的时候可能非常有用,但其他一些几乎总是一个不好的主意,在本文中显示这些是为了引起你的兴趣。在决定何时使用本文中介绍的功能时,你应该运用自己的判断:仅仅因为可以做到并不意味着就应该这样做。好的,现在开始:
1.注释Implementation
从Java Development Kit(JDK)5开始,注释已成为许多Java应用程序和框架的组成部分。在绝大多数情况下,注释将应用于语言构造,例如类,字段,方法等,但是在另一种情况下,可以应用注释:作为可继承(implementable )的接口。例如,假设我们具有以下注释定义:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
String name();
}
通常,我们将这个注释应用于方法,如下所示:
public 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();
尽管我们的注释和接口几乎相同,重复非常明显,似乎没有一种方法可以将这两个结构合并。幸运的是,外观具有欺骗性,并且有一种将这两种结构合并的技术:注解implements:
public 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");
因此,当我们希望初始化一个对象时,我们将初始化逻辑整合到该对象的构造函数中。例如,我们在Foo
类的参数化构造函数中设置其name
字段。尽管似乎可以合理地假设所有初始化逻辑都在类的构造函数或构造函数集中找到,但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");
}
}
当三个初始化技术都出现在一起的时候,始终首先执行静态初始化(当类加载到内存中时),然后按实例初始化程序声明它们的顺序执行,最后则是构造器。引入超类时,执行顺序会略有变化:
1. 父类的静态初始化
2. 子类的静态初始化
3. 父类的实例初始化
4. 父类的构造器
5. 子类的实例初始化
6. 子类的构造器
例如,我们可以创建以下应用程序:
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. 双括号初始化
许多编程语言都包含某种语法机制,可在不使用冗长的样板代码的情况下快速简洁地创建列表或Map(或字典)。例如,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
完全相同。主要区别之一是我们的内部类对包含的类有隐式引用(以捕获的此变量的形式),因为我们正在创建一个非静态内部类。这使我们能够编写一些有趣的(即使不是复杂的)逻辑,例如将捕获的此变量添加到匿名的,双括号初始化的内部类中:
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);
}}
}
重新创建双括号初始化的ArrayList
的构造后,一旦我们创建了非静态内部类,就可以使用实例初始化(如上所述)在实例化匿名内部类时执行三个初始元素的加法。由于匿名内部类会立即实例化,并且匿名内部类中只有一个对象存在,因此我们实质上创建了一个非静态内部单例对象,该对象在创建时会添加三个初始元素。如果我们分开一对大括号,这将变得更加明显,其中一个大括号清楚地构成了匿名内部类的定义,另一个大括号表示了实例初始化逻辑的开始:
List<Integer> myInts = new ArrayList<>() {
{
add(1);
add(2);
add(3);
}
};
尽管此技巧很有用,但JDK 9 (JEP 269)已用一组用于List
(以及许多其他集合类型)的静态工厂方法代替了此技巧的实用程序。 例如,我们可以使用这些静态工厂方法创建上面的列表,如以下清单所示:
List<Integer> myInts = List.of(1, 2, 3);
之所以需要这种静态工厂技术,主要有两个原因:(1)没有创建匿名内部类;(2)减少了创建列表所需的样板代码。以这种方式创建List
的警告是,结果List
是不可变的,因此一旦创建后就不能修改。为了创建具有所需初始元素的可变List
,我们坚持使用naive
技术或双括号初始化。
请注意,初始化初始化,双括号初始化和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格式的文本文件。添加此回车符将分配值= 8推送到注释后面的行,确保它已执行。这意味着上面的代码片段实际上等于以下内容:
public static void main(String args[]) {
int value = 5;
//
value = 8;
System.out.println(value);
}
尽管这似乎是Java中的错误,但实际上它是该语言的自觉包含。Java的最初目标是创建独立于平台的语言(因此创建Java虚拟机或JVM),并且源代码的互操作性是此目标的关键方面。通过允许Java源代码包含Unicode字符,我们可以通用方式包含非拉丁字符。这确保了可以在任何其他地方执行写在世界一个地区中的代码(其中可能包含非拉丁字符,例如注释中的代码)。
我们可以将其发挥到极致,甚至可以使用Unicode编写整个应用程序。例如,以下程序做什么(从Java获得源代码:在注释中执行代码?!)?
\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();
现在,我们还可以在需要Speaker对象的任何地方使用Person的实例。 此外,我们还可以在每个常量的基础上提供接口抽象方法的实现(称为特定于常量的方法):
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),则定义该常量的枚举应实现接口类型。
结论
在本文中,我们研究了Java中的五个隐藏秘密,即:
(1)可以扩展注释;
(2)实例初始化可用于在实例化时配置对象;
(3)双括号初始化可用于执行 创建匿名内部类时的说明;
(4)有时可以执行注释;
(5)枚举可以实现接口。
尽管其中一些功能有其适当的用途,但应避免使用其中某些功能(即创建可执行注释)。在决定使用这些机密时,请务必遵循以下规则:仅仅是因为可以做些事情,并不意味着应该这样做。
好了,今天的分享就到这,想要更多Java资料的童鞋可以豆油小U分享给你, 让我们抽丝剥茧,细说架构那些事~
