Java高级系列——通用编程指南

一、介绍

本文我们将会继续讨论Java中一些优秀且强大的编程风格中的一些通用规则。我们将要讨论的这些规则中的一些我们在前面的文章中已经提及,然而为了提高Java开发者的相关技巧,有很多新的实战建议本文我们将会讨论。

二、变量作用域(Variable scopes)

在如何设计类和接口一文我们已经讨论过在设计类和接口时如何使用可见性规则和可访问性规则来限制他们的作用域。但是我们没有讨论过局部变量,即在方法实现时所使用的变量。

在Java语言中,每一个局部变量一旦声明,它就会有一个作用域。变量从它声明的地方一直到方法末尾(或者代码块的末尾)都是可见的。因此,对于局部变量的作用域,我们只需要遵守一个单独的规则,即声明局部变量一定要尽可能的靠近它所被使用的地方。让我们来看一个简单的经典例子:

for(final Locale locale: Locale.getAvailableLocales()) {
    // Some implementation here
}

try( final InputStream in = new FileInputStream("file.txt")) {
    // Some implementation here
}

上面的两段代码中,它们局部变量代码的作用域都被限制在它们所声明的执行块中。一旦代码执行到该块的末尾,局部变量将会失效并且将不可见。这种声明方式看起来非常的清晰明了,但是随着Java 8的发布并引入Lambdas之后,很多我们以前所熟知的使用局部变量的惯例在慢慢被废弃。让我们使用Lambdas表达式重写前面的foreach循环:

Arrays.stream(Locale.getAvailableLocales()).forEach((locale) -> {
    // Some implementation here
});

局部变量变成了本身作为参数被传递给forEach方法的函数的参数。

三、类的字段和局部变量

在Java中每个方法都是属于类或接口(Java 8之后接口中被声明为default的方法属于接口)。因此,方法中局部变量和类成员的使用可能会导致命名冲突。

Java编译器可以从作用域中找出正确的变量,虽然如此,但是编译器自动从作用域找出的变量这有可能不是开发者想要使用的。当前的Java IDE在一些命名冲突的地方都做了很多工作(警告、高亮代码)来暗示开发者。但是在开发过程中最好还是多思考思考。我们来看一个例子:

public class LocalVariableAndClassMember {
    private long value;

    public long calculateValue(final long initial) {
        long value = initial;

        value *= 10;
        value += value;

        return value;
    }
}

这个例子看起来相当的简单,但是他存在一个隐情。方法calculateValue引入了一个名为value的局部变量,该变量隐藏了类中同名的成员。第8行我们想做的是求类成员和局部变量的和,但是按照上面的这段代码来实现的话,它所做的事情却是截然不同的。正确的代码应该如下(使用this关键字):

public class LocalVariableAndClassMember {
    private long value;

    public long calculateValue( final long initial ) {
        long value = initial;

        value *= 10;
        value += this.value;

        return value;
    }
}

有些天真的实现突出了这个重要的问题,在某些情况下可能需要花费几个小时的时间去调试和解决。

四、方法参数和局部变量

没有经验的Java开发者经常会掉进去的另外一个坑就是使用方法参数作为局部变量。Java允许使用不同的值重新分配非final类型的方法参数(然而他对原来的值并没有任何影响)。比如:

public String sanitize( String str ) {
    if( !str.isEmpty() ) {
        str = str.trim();
    }

    str = str.toLowerCase();
    return str;
}

上面这段代码看起来可能不是那么漂亮,但是它足以说明这个问题:方法参数str被重新分配为另外一个值(基本上被用作局部变量)。任何一种情况下(没有例外)这种模式都要杜绝(比如定义方法参数为final类型)。比如:

public String sanitize( final String str ) {
    String sanitized = str;

    if( !str.isEmpty() ) {
        sanitized = str.trim();
    }

    sanitized = sanitized.toLowerCase();
    return sanitized;
}

遵循这个简单规则的代码更容易追踪和推导,即使以引入局部变量的代价。

五、装箱和拆箱(Boxing and unboxing)

装箱和拆箱是Java语言中转换基本类型(如int,long,double)和基本类型的包装类(如Integer, Long, Double)的通用技术的统称。在如何使用、何时使用泛型的一文中我们已经提到过如何使用基本类型的包装类作为泛型类型的参数。

虽然Java编译器已经尽其最大努力通过执行自动装箱隐藏转化过程,但是有时这种方式也会让事情变得有些糟糕并会导致一些意想不到的结果。我们看一个例子:

public static void calculate( final long value ) {
    // Some implementation here
}

final Long value = null;
calculate(value);

上面这段代码可以编译通过,但是当转换Long为long的时候会抛出一个空指针异常(NullPointerException)。在这种情况下我们就建议使用基本类型。

六、接口(Interfaces)

在如何设计类和接口一文中,我们已经讨论过接口和基于约定的开发方式,并且强调了相对于具体类来讲更偏向于使用接口的事实。本节我们将更倾向于通过实例的形式向您再次阐述并说服你首选接口。

接口没有绑定任何特定实现(接口默认方法除外)。它们仅仅作为一种约定,因此这些约定的实现方式就会更自由,并且灵活性也较强。特别是在实现调用外部系统或者服务的时候,接口这种灵活性就会变得越发重要。我们来看一个简单的接口和它的实现:

public interface TimezoneService {
    TimeZone getTimeZone(final double lat, final double lon) throws IOException;
}

public class TimezoneServiceImpl implements TimezoneService {
    @Override
    public TimeZone getTimeZone(final double lat, final double lon) throws IOException {
        final URL url = new URL(String.format("http://api.geonames.org/timezone?lat=%.2f&lng=%.2f&username=demo",lat, lon));

        final HttpURLConnection connection = (HttpURLConnection)url.openConnection();

        connection.setRequestMethod( "GET" );
        connection.setConnectTimeout( 1000 );
        connection.setReadTimeout( 1000 );
        connection.connect();

        int status = connection.getResponseCode();
        if (status == 200) {
            // Do something here
        }

        return TimeZone.getDefault();
    }
}

上面这段代码演示了一个经典的接口/实现模式。这个实现使用了外部HTTP服务(http://api.geonames.org/)获取指定地区的时区。然而,由于接口约定的驱使,引入另外一种实现就相当的容易,比如,数据库或者文本文件。基于此,接口在设计可测试代码的场景提供了很大的帮助。例如,在每次测试运行时调用外部服务并不总是切实可行的,所以提供替代的虚拟实现(也称为stub或mock)是有意义的:

public class TimezoneServiceTestImpl implements TimezoneService {
    @Override
    public TimeZone getTimeZone(final double lat, final double lon) throws IOException {
        return TimeZone.getDefault();
    }
}

这个实现可以用在任何需要TimezoneService接口的地方,从而将测试场景与外部组件的依赖隔离。

Java标准集合库中封装了很多使用接口的合适的优秀示例。Collection, List, Set,所有这些接口都被多个实现支持,这些实现可以在任何需要使用对应接口的地方被无缝替换和互换,例如:

public static< T > void print(final Collection< T > collection) {
    for(final T element: collection) {
        System.out.println(element);
    }
}

print(new HashSet<Object>(/* ... */ ));
print(new ArrayList<Integer>(/* ... */ ));
print(new TreeSet<String>(/* ... */ ));
print(new Vector<Long>(/* ... */ ));

七、字符串(Strings)

String是Java中使用最广泛的的类型之一,同时通过实践,在其他语言中也是如此。Java语言通过天然支持合并(concatenations)和比较(comparison)简化了很多字符串的常规操作。此外,Java标准库提供了很多不同的类让字符串的操作更高效,这也是我们本节需要介绍的。

在Java中,String是不可变对象,用UTF-16格式表示。每次合并(concatenate)字符串(或者执行任何修改原字符串的操作)都会创建一个新的String实例。由于这个事实,合并操作可能会稍微有些低效,因为这种操作会导致创建许多中间字符串实例(会创造很多垃圾)。

但是Java标准库提供了两个非常有用的帮助类来帮助字符串操作,这两个帮助类就是StringBuilder和StringBuffer(这两个类唯一不同的就是StringBuilder是线程安全的,而StringBuffer不是)。我们来看一个使用StringBuilder的例子:

final StringBuilder sb = new StringBuilder();

for(int i = 1; i <= 10; ++i) {
    sb.append(" ");
    sb.append(i);
}

sb.deleteCharAt(0);
sb.insert(0, "[");
sb.replace(sb.length() - 3, sb.length(), "]");

虽然我们推荐使用 StringBuilder / StringBuffer操作字符串,但是在连接两个或三个字符串的简单情况下,可能会看起来过度消耗,因此可以使用常规+运算符来代替。如:

String userId = "user:" + new Random().nextInt(100);

对于直接合并来说,更好的选择是使用字符串格式化,Java标准库也通过提供静态帮助方法String.format来提供字符串格式化帮助。它支持丰富的格式化标识组合,包括数字、字符、日期/时间等(完整参考请查看官方文档)。我们来看一个比较有说服力的例子:

格式化表达式 结果
String.format( “%04d”, 1); 0001
String.format( “%.2f”, 12.324234d); 12.32
String.format( “%tR”, new Date()); 21:11
String.format( “%tF”, new Date()); 2018-01-19
String.format( “%d%%”, 12); 12%

String.format方法提供了一种简洁容易的方式将不同数据类型构造为合适的字符串。值得一提的是,一些现代Java IDE能够根据传递给String.format方法的参数分析格式规范,并在发现任何不匹配的情况下警告开发人员。

八、命名规约(Naming conventions)

Java并没有严格的强制开发者一定要遵守某种命名规约,然而社区开发出了一组非常容易遵循的规则,这些规则可以让你的代码看起来和标准代码库以及其他广泛使用的项目的代码基本一致。

  • 包名称(package )统一为小写字母:org.junit, com.fasterxml.jackson, javax.json;

  • 类、枚举、接口以及注解(class, enum, interface or annotation)名称统一以大写字母开头:StringBuilder, Runnable, @Override;

  • 方法和字段(method 、field)名称(除了final和static之外)统一为驼峰规则:isEmpty, format, addAll;

  • static、final字段或者枚举常量的名称统一为大写字母:LOG, MIN_RADIX, INSTANCE;

  • 局部变量和方法参数统一为驼峰规则: str, newLength, minimumCapacity;
    泛型类型参数通常使用单个大写字母代替:T, U, E;

通过遵循这些简单的命名规约,可以让你的代码看起来简单明了,同时和其他的库和框架的代码风格基本一致,给人的感觉就好像出自同一个人一样。

九、标准代码库(Standard Libraries)

无论你所开发的是那种类型的Java项目,Java标准代码库都是你最好的朋友。不否认它有一些模糊的边界和一些奇怪的设计决定,但是这些通过专家所写出来的代码中99%是高质量的,值得我们去学习。

每次Java新版本发布都会为已存在的库带来新的特性(也有可能是弃用一些老的特性),同时也会增加许多新的库。Java 5在java.util.concurrent包下引入了新的并发库,Java 6发布了脚本支持(javax.script包)和Java编译器API(javax.tools包),Java 7带来了很多对于java.util.concurrent的优化,在java.nio.file包中引进新的I/O库以及在java.lang.invoke包中引进了动态语言支持。最终,Java 8发布了期待已久的 date/time API,这些API在java.time包中。

Java是一个不断进化的平台,最重要的是它一直在不断的革新。当你想引入第三方库或者框架到你自己的项目中时,首先需要先确定Java标准库中不存在所需的功能(实际上,很多特定的高性能算法实现比标准库更好,但是大多数情况下,你需要考虑的是你是否真的用得着)。

十、不可变性(Immutability)

在本系列文章可能我们都会提及不可变性,这一部分我们仍然提醒你:请认真对待。如果你设计一个类或者实现一个方法,都需要提供不可变性的保证,这样的话可以在大多数情况下使用它,而不用担心并发修改。这将让你的研发生涯变得相当的轻松。

十一、测试(Testing)

在Java社区,测试驱动开发(TDD)实践相当流行,TDD提高了所开发的代码的质量。TDD带来的很多的好处,可惜的是,Java标准库至今还没有包含任何测试框架或脚手架。

虽然如此,在当今的Java开发中测试已经变成了不可或缺的一部分,并且本节我们将会覆盖一些JUnit框架的基础内容。本质上,在JUnit中,每个测试是期望对象的状态或行为的一组断言。

写出很好的测试的秘诀就是保持简短和简单,一次测试一个问题。作为练习,让我们写一组测试去验证String.format的功能并返回期望的结果。

public class StringFormatTestCase {
    @Test
    public void testNumberFormattingWithLeadingZeros() {
        final String formatted = String.format("%04d", 1);
        assertThat(formatted, equalTo("0001"));
    }

    @Test
    public void testDoubleFormattingWithTwoDecimalPoints() {
        final String formatted = String.format("%.2f", 12.324234d);
        assertThat( formatted, equalTo("12.32"));
    }
}

如今,平均每个Java项目包含数百个测试用例,对于正在开发中的项目,这些用例的执行为开发人员在回归或功能上提供了快速反馈。

本文我们讨论了Java变成中的一些通用指南,在本系列文章的下一部分我们将会讨论Java语言中异常(exception)的相关特性,并且阐述如何使用以及何时使用他们。

猜你喜欢

转载自blog.csdn.net/zyhlwzy/article/details/79108021