本章包括 21 个涉及 JEP286 或 Java 局部变量类型推断(LVTI)的问题,也称为var
类型。这些问题经过精心设计,以揭示最佳实践和使用var
时所涉及的常见错误。到本章结束时,您将了解到将var
推向生产所需的所有知识。
问题
使用以下问题来测试您的类型推断编程能力。我强烈建议您在使用解决方案和下载示例程序之前,先尝试一下每个问题:
简单var示例:编写一个程序,举例说明类型推断(var)在代码可读性方面的正确用法。
将var与原始类型结合使用:编写一个程序,举例说明将var与 Java 原始类型(int、long、float、double结合使用。
使用var和隐式类型转换来维持代码的可维护性:编写一个程序,举例说明var和隐式类型转换如何维持代码的可维护性。
显式向下转换或更好地避免var:编写一个程序,举例说明var和显式向下转换的组合,并解释为什么要避免var。
如果被调用的名称没有包含足够的人性化类型信息,请避免使用var:请举例说明应避免使用var,因为它与被调用的名称的组合会导致人性化信息的丢失。
结合 LVTI 和面向接口编程技术:编写一个程序,通过面向接口编程技术来举例说明var的用法。
结合 LVTI 和菱形运算符:编写一个程序,举例说明var和菱形运算符的用法。
使用var分配数组:编写一个将数组分配给var的程序。
在复合声明中使用 LVTI:解释并举例说明 LVTI 在复合声明中的用法。
LVTI 和变量范围:解释并举例说明为什么 LVTI 应该尽可能地缩小变量的范围。
LVTI 和三元运算符:编写几个代码片段,举例说明 LVTI 和三元运算符组合的优点。
LVTI 和for循环:写几个例子来举例说明 LVTI 在for循环中的用法。
LVTI 和流:编写几个代码片段,举例说明 LVTI 和 Java 流的用法。
使用 LVTI 分解嵌套的/大的表达式链:编写一个程序,举例说明如何使用 LVTI 分解嵌套的/大的表达式链。
LVTI 和方法返回和参数类型:编写几个代码片段,举例说明 LVTI 和 Java 方法在返回和参数类型方面的用法。
LVTI 和匿名类:编写几个代码片段,举例说明 LVTI 在匿名类中的用法。
LVTI 可以是final和有效的final:写几个代码片段,举例说明 LVTI 如何用于final和有效的final变量。
LVTI 和 Lambda:通过几个代码片段解释如何将 LVTI 与 Lambda 表达式结合使用。
LVTI 和null初始化器、实例变量和catch块变量:举例说明如何将 LVTI 与null初始化器、实例变量和catch块结合使用。
LVTI 和泛型类型T:编写几个代码片段,举例说明如何将 LVTI 与泛型类型结合使用。
LVTI、通配符、协变和逆变:编写几个代码片段,举例说明如何将 LVTI 与通配符、协变和逆变结合使用。
解决方案
以下各节介绍上述问题的解决方案。记住,通常没有一个正确的方法来解决一个特定的问题。另外,请记住,这里显示的解释仅包括解决问题所需的最有趣和最重要的细节。您可以下载示例解决方案以查看更多详细信息并尝试程序。
78 简单var
示例
从版本 10 开始,Java 附带了 JEP286 或 JavaLVTI,也称为var
类型。
var标识符不是 Java 关键字,而是保留类型名。
这是一个 100% 编译特性,在字节码、运行时或性能方面没有任何副作用。简而言之,LVTI 应用于局部变量,其工作方式如下:编译器检查右侧并推断出实类型(如果右侧是一个初始化器,则使用该类型)。
此功能可确保编译时安全。这意味着我们不能编译一个试图实现错误赋值的应用。如果编译器已经推断出var的具体/实际类型,我们只能赋值该类型的值。
LVTI 有很多好处;例如,它减少了代码的冗长,减少了冗余和样板代码。此外,LVTI 可以减少编写代码所花的时间,特别是在涉及大量声明的情况下,如下所示:
// without var Map<Boolean, List<Integer>> evenAndOddMap... // with var var evenAndOddMap = ...
一个有争议的优点是代码可读性。一些声音支持使用var
会降低代码可读性,而另一些声音则支持相反的观点。根据用例的不同,它可能需要在可读性上进行权衡,但事实是,通常情况下,我们非常关注字段(实例变量)的有意义的名称,而忽略了局部变量的名称。例如,让我们考虑以下方法:
public Object fetchTransferableData(String data) throws UnsupportedFlavorException, IOException { StringSelection ss = new StringSelection(data); DataFlavor[] df = ss.getTransferDataFlavors(); Object obj = ss.getTransferData(df[0]); return obj; }
这是一个简短的方法;它有一个有意义的名称和一个干净的实现。但是检查局部变量的名称。它们的名称大大减少(它们只是快捷方式),但这不是问题,因为左侧提供了足够的信息,我们可以很容易地理解每个局部变量的类型。现在,让我们使用 LVTI 编写以下代码:
public Object fetchTransferableData(String data) throws UnsupportedFlavorException, IOException { var ss = new StringSelection(data); var df = ss.getTransferDataFlavors(); var obj = ss.getTransferData(df[0]); return obj; }
显然,代码的可读性降低了,因为现在很难推断出局部变量的类型。如下面的屏幕截图所示,编译器在推断正确的类型方面没有问题,但是对于人类来说,这要困难得多:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FcuV0SJX-1657077646421)(img/95cb6f1d-d16c-4a89-b68b-50281887697b.png)]
这个问题的解决方案是在依赖 LVTI 时为局部变量提供一个有意义的名称。例如,如果提供了局部变量的名称,代码可以恢复可读性,如下所示:
public Object fetchTransferableData(String data) throws UnsupportedFlavorException, IOException { var stringSelection = new StringSelection(data); var dataFlavorsArray = stringSelection.getTransferDataFlavors(); var obj = stringSelection.getTransferData(dataFlavorsArray[0]); return obj; }
然而,可读性问题也是由这样一个事实引起的:通常,我们倾向于将类型视为主要信息,将变量名视为次要信息,而这应该是相反的。
让我们再看两个例子来执行上述语句。使用集合(例如,List
)的方法如下:
// Avoid public List<Player> fetchPlayersByTournament(String tournament) { var t = tournamentRepository.findByName(tournament); var p = t.getPlayers(); return p; } // Prefer public List<Player> fetchPlayersByTournament(String tournament) { var tournamentName = tournamentRepository.findByName(tournament); var playerList = tournamentName.getPlayers(); return playerList; }
为局部变量提供有意义的名称并不意味着陷入过度命名技术。
例如,通过简单地重复类型名来避免命名变量:
// Avoid var fileCacheImageOutputStream = new FileCacheImageOutputStream(..., ...); // Prefer var outputStream = new FileCacheImageOutputStream(..., ...); // Or var outputStreamOfFoo = new FileCacheImageOutputStream(..., ...);
79 对原始类型使用var
将 LVTI 与原始类型(int
、long
、float
和double
)一起使用的问题是,预期类型和推断类型可能不同。显然,这会导致代码中的混乱和意外行为。
这种情况下的犯罪方是var
类型使用的隐式类型转换。
例如,让我们考虑以下两个依赖显式原始类型的声明:
boolean valid = true; // this is of type boolean char c = 'c'; // this is of type char
现在,让我们用 LVTI 替换显式原始类型:
var valid = true; // inferred as boolean var c = 'c'; // inferred as char
很好!到目前为止没有问题!现在,让我们看看另一组基于显式原始类型的声明:
int intNumber = 10; // this is of type int long longNumber = 10; // this is of type long float floatNumber = 10; // this is of type float, 10.0 double doubleNumber = 10; // this is of type double, 10.0
让我们按照第一个示例中的逻辑,用 LVTI 替换显式原始类型:
// Avoid var intNumber = 10; // inferred as int var longNumber = 10; // inferred as int var floatNumber = 10; // inferred as int var doubleNumber = 10; // inferred as int
根据以下屏幕截图,所有四个变量都被推断为整数:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JJrzLR4d-1657077646422)(img/ec6aa8f3-a41c-4cc5-bff2-9d08083bcc91.png)]
这个问题的解决方案包括使用显式 Java 字面值:
// Prefer var intNumber = 10; // inferred as int var longNumber = 10L; // inferred as long var floatNumber = 10F; // inferred as float, 10.0 var doubleNumber = 10D; // inferred as double, 10.0
最后,让我们考虑一个带小数的数字的情况,如下所示:
var floatNumber = 10.5; // inferred as double
变量名表明10.5
是float
,但实际上是推断为double
。因此,即使是带小数的数字(尤其是带小数的数字),也建议使用字面值:
var floatNumber = 10.5F; // inferred as float
80 使用var
和隐式类型转换来维持代码的可维护性
在上一节中,“将var
与原始类型结合使用”,我们看到将var
与隐式类型转换结合使用会产生实际问题。但在某些情况下,这种组合可能是有利的,并维持代码的可维护性。
让我们考虑以下场景,我们需要编写一个方法,该方法位于名为ShoppingAddicted的外部 API 的两个现有方法之间(通过推断,这些方法可以是两个 Web 服务、端点等)。有一种方法专门用于返回给定购物车的最佳价格。基本上,这种方法需要一堆产品,并查询不同的在线商店,以获取最佳价格。
结果价格返回为int。此方法的存根如下所示:
public static int fetchBestPrice(String[] products) { float realprice = 399.99F; // code to query the prices in stores int price = (int) realprice; return price; }
另一种方法将价格作为int
接收并执行支付。如果支付成功,则返回true
:
public static boolean debitCard(int amount) { return true; }
现在,通过对该代码进行编程,我们的方法将充当客户端,如下所示(客户可以决定购买哪些商品,我们的代码将为他们返回最佳价格并相应地借记卡):
// Avoid public static boolean purchaseCart(long customerId) { int price = ShoppingAddicted.fetchBestPrice(new String[0]); boolean paid = ShoppingAddicted.debitCard(price); return paid; }
但是过了一段时间,ShoppingAddicted
API 的拥有者意识到他们通过将实际价格转换成int
来赔钱(例如,实际价格是 399.99,但在int
形式中,它是 399.0,这意味着损失 99 美分)。因此,他们决定放弃这种做法,将实际价格返回为float
:
public static float fetchBestPrice(String[] products) { float realprice = 399.99F; // code to query the prices in stores return realprice; }
因为返回的价格是float
,所以debitCard()
也会更新:
public static boolean debitCard(float amount) { return true; }
但是,一旦我们升级到新版本的ShoppingAddictedAPI,代码将失败,并有可能从float到int异常的有损转换。这是正常的,因为我们的代码需要int。由于我们的代码不能很好地容忍这些修改,因此需要相应地修改代码。
然而,如果我们已经预见到这种情况,并且使用了var而不是int,那么由于隐式类型转换,代码将不会出现问题:
// Prefer public static boolean purchaseCart(long customerId) { var price = ShoppingAddicted.fetchBestPrice(new String[0]); var paid = ShoppingAddicted.debitCard(price); return paid; }
81 显式向下转换或更好地避免var
在“将var与原始类型结合使用”一节中,我们讨论了将字面值与原始类型结合使用(int、long、float和double来避免隐式类型转换带来的问题。但并非所有 Java 原始类型都可以利用字面值。在这种情况下,最好的方法是避免使用var。但让我们看看为什么!
检查以下关于byte
和short
变量的声明:
byte byteNumber = 25; // this is of type byte short shortNumber = 1463; // this is of type short
如果我们用var
替换显式类型,那么推断的类型将是int
:
var byteNumber = 25; // inferred as int var shortNumber = 1463; // inferred as int
不幸的是,这两种基本类型没有可用的字面值。帮助编译器推断正确类型的唯一方法是依赖显式向下转换:
var byteNumber = (byte) 25; // inferred as byte var shortNumber = (short) 1463; // inferred as short
虽然这段代码编译成功并按预期工作,但我们不能说使用var
比使用显式类型带来了任何价值。因此,在这种情况下,最好避免var
和显式的向下转型。
82 如果被调用的名称没有包含足够的类型信息,请避免使用var
好吧,var
不是一颗银弹,这个问题将再次凸显这一点。以下代码片段可以使用显式类型或var
编写,而不会丢失信息:
// using explicit types MemoryCacheImageInputStream is = new MemoryCacheImageInputStream(...); JavaCompiler jc = ToolProvider.getSystemJavaCompiler(); StandardJavaFileManager fm = compiler.getStandardFileManager(...);
因此,将前面的代码片段迁移到var
将产生以下代码(通过从右侧目视检查被调用的名称来选择变量名称):
// using var var inputStream = new MemoryCacheImageInputStream(...); var compiler = ToolProvider.getSystemJavaCompiler(); var fileManager = compiler.getStandardFileManager(...);
同样的情况也会发生在过度命名的边界上:
// using var var inputStreamOfCachedImages = new MemoryCacheImageInputStream(...); var javaCompiler = ToolProvider.getSystemJavaCompiler(); var standardFileManager = compiler.getStandardFileManager(...);
因此,前面的代码在选择变量的名称和可读性时不会引起任何问题。所谓的名称包含了足够的信息,让人类对var
感到舒服。
但让我们考虑以下代码片段:
// Avoid public File fetchBinContent() { return new File(...); } // called from another place // notice the variable name, bin var bin = fetchBinContent();
对于人类来说,如果不检查名称、fetchBinContent()
的返回类型,就很难推断出名称返回的类型。根据经验,在这种情况下,解决方案应该避免var
并依赖显式类型,因为右侧没有足够的信息让我们为变量选择合适的名称并获得可读性很高的代码:
// called from another place // now the left-hand side contains enough information File bin = fetchBinContent();
因此,如果将var与被调用的名称组合使用导致清晰度损失,则最好避免使用var。忽略此语句可能会导致混淆,并会增加理解和/或扩展代码所需的时间。
考虑另一个基于java.nio.channels.Selector类的例子。此类公开了一个名为open()的static方法,该方法返回一个新打开的Selector。但是,如果我们在一个用var声明的变量中捕获这个返回值,我们很可能会认为这个方法可能返回一个boolean,表示打开当前选择器的成功。使用var而不考虑可能的清晰度损失会产生这些问题。像这样的一些问题和代码将成为一个真正的痛苦。
83 LVTI 与面向接口编程技术相结合
Java 最佳实践鼓励我们将代码绑定到抽象。换句话说,我们需要依赖于面向接口编程的技术。
这种技术非常适合于集合声明。例如,建议声明ArrayList
如下:
List<String> players = new ArrayList<>();
我们也应该避免这样的事情:
ArrayList<String> players = new ArrayList<>();
通过遵循第一个示例,代码实例化了ArrayList类(或HashSet、HashMap等),但声明了一个List类型的变量(或Set、Map等)。由于List、Set、Map以及更多的都是接口(或契约),因此很容易用List(Set和Map的其他实现来替换实例化,而无需对代码进行后续修改。
不幸的是,LVTI 不能利用面向接口编程技术。换句话说,当我们使用var时,推断的类型是具体的实现,而不是合同。例如,将List<String>替换为var将导致推断类型ArrayList<String>:
// inferred as ArrayList<String> var playerList = new ArrayList<String>();
然而,有一些解释支持这种行为:
LVTI 在局部级别(局部变量)起作用,其中面向接口编程技术的的使用少于方法参数/返回类型或字段类型。
由于局部变量的作用域很小,因此切换到另一个实现所引起的修改也应该很小。切换实现对检测和修复代码的影响应该很小。
LVTI 将右侧的代码视为一个用于推断实际类型的初始化器。如果将来要修改这个初始化器,那么推断的类型可能不同,这将导致使用此变量的代码出现问题。
84 LVTI 和菱形运算符相结合
根据经验,如果右侧不存在推断预期类型所需的信息,则 LVTI 与菱形运算符结合可能会导致意外的推断类型。
在 JDK7 之前,即 Coin 项目,List<String>
将声明如下:
List<String> players = new ArrayList<String>();
基本上,前面的示例显式指定泛型类的实例化参数类型。从 JDK7 开始,Coin 项目引入了菱形操作符,可以推断泛型类实例化参数类型,如下所示:
List<String> players = new ArrayList<>();
现在,如果我们从 LVTI 的角度来考虑这个例子,我们将得到以下结果:
var playerList = new ArrayList<>();
但是现在推断出的类型是什么呢?好吧,我们有一个问题,因为推断的类型将是ArrayList<Object>,而不是ArrayList<String>。解释很明显:推断预期类型(String所需的信息不存在(注意,右侧没有提到String类型)。这指示 LVTI 推断出最广泛适用的类型,在本例中是Object。
但是如果ArrayList<Object>不是我们的意图,那么我们需要一个解决这个问题的方法。解决方案是提供推断预期类型所需的信息,如下所示:
var playerList = new ArrayList<String>();
现在,推断的类型是ArrayList<String>。也可以间接推断类型。请参见以下示例:
var playerStack = new ArrayDeque<String>(); // inferred as ArrayList<String> var playerList = new ArrayList<>(playerStack);
也可以通过以下方式间接推断:
Player p1 = new Player(); Player p2 = new Player(); var listOfPlayer = List.of(p1, p2); // inferred as List<Player> // Don't do this! var listOfPlayer = new ArrayList<>(); // inferred as ArrayList<Object> listOfPlayer.add(p1); listOfPlayer.add(p2);