背景图片
跳转到内容

Effective Java EXTRA - 第2章 创建和销毁对象 ​

信息

引用内容的页码来源于《Effective Java 中文版(原书第3版)》(人民邮电出版社,ISBN: 978-7-115-62898-5,2024年3月第1版),仅供参考

条目1: 用静态方法代替构造器 ​

请注意,静态工厂方法与《设计模式:可复用面向对象软件的基础》[Gamma95]中的工厂方法(Factory Method)模式并不相同。(P4)

原本的工厂方法实现是这样的(具体可以看脚注)[1]:

text
┌─────────────┐      ┌─────────────┐
│   Product   │      │   Factory   │
└─────────────┘      └─────────────┘
       ▲                    ▲
       │                    │
┌─────────────┐      ┌─────────────┐
│ ProductImpl │<─ ─ ─│ FactoryImpl │
└─────────────┘      └─────────────┘

也就是完全分离对象的创建和使用,使得客户端可以在仅引用Product和Factory接口时不用了解具体的实现类,算是面向接口编程的一个典型案例了。

此处说的静态工厂方法也称为简单工厂方法,具体就是在上面的基础上将四个类进行合并,用静态方法(而不是一个类)来作为创建对象的工厂。这样写能在工厂并不复杂,且工厂实现单一时能省去大部分的样板代码,而实现相同的功能。


例如,构造器BigInteger(int, int, Random)会返回一个为可能素数(probable prime)的BigInteger,但如果用一个名为BigInteger.probablePrime的静态工厂方法来表示,效果会更好。(P4)

相关源码如下(提取自OpenJDK)

点击展开代码
java
/**
 * Constructs a randomly generated positive BigInteger that is probably
 * prime, with the specified bitLength.
 *
 * @apiNote It is recommended that the {@link #probablePrime probablePrime}
 * method be used in preference to this constructor unless there
 * is a compelling need to specify a certainty.
 *
 * @param  bitLength bitLength of the returned BigInteger.
 * @param  certainty a measure of the uncertainty that the caller is
 *         willing to tolerate.  The probability that the new BigInteger
 *         represents a prime number will exceed
 *         (1 - 1/2<sup>{@code certainty}</sup>).  The execution time of
 *         this constructor is proportional to the value of this parameter.
 * @param  rnd source of random bits used to select candidates to be
 *         tested for primality.
 * @throws ArithmeticException {@code bitLength < 2} or {@code bitLength} is too large.
 * @see    #bitLength()
 */
public BigInteger(int bitLength, int certainty, Random rnd) {
    BigInteger prime;

    if (bitLength < 2)
        throw new ArithmeticException("bitLength < 2");
    prime = (bitLength < SMALL_PRIME_THRESHOLD
                            ? smallPrime(bitLength, certainty, rnd)
                            : largePrime(bitLength, certainty, rnd));
    signum = 1;
    mag = prime.mag;
}

java
/**
 * Returns a positive BigInteger that is probably prime, with the
 * specified bitLength. The probability that a BigInteger returned
 * by this method is composite does not exceed 2<sup>-100</sup>.
 *
 * @param  bitLength bitLength of the returned BigInteger.
 * @param  rnd source of random bits used to select candidates to be
 *         tested for primality.
 * @return a BigInteger of {@code bitLength} bits that is probably prime
 * @throws ArithmeticException {@code bitLength < 2} or {@code bitLength} is too large.
 * @see    #bitLength()
 * @since 1.4
 */
public static BigInteger probablePrime(int bitLength, Random rnd) {
    if (bitLength < 2)
        throw new ArithmeticException("bitLength < 2");

    return (bitLength < SMALL_PRIME_THRESHOLD ?
            smallPrime(bitLength, DEFAULT_PRIME_CERTAINTY, rnd) :
            largePrime(bitLength, DEFAULT_PRIME_CERTAINTY, rnd));
}

注意在第二段源码中,certainty参数被DEFAULT_PRIME_CERTAINTY常量取代(我的实现的常量值是100)。对BigInteger的具体解析以后可能会单独写博客。


这种技术和享元(Flyweight)模式[Gamma95]类似。

具体可参阅菜鸟教程相关页面。以后可能会围绕Java中常见的设计模式写一个专栏。


这种灵活的静态工厂方法构成了服务提供者框架(Service Provider Framework)的基础,例如Java数据库连接(Java Database Connectivity,JDBC)API。(P6)

条目2:当构造器参数较多时考虑使用生成器 ​

对于构造器存在很多可选参数的情况,还有一种选择,就是使用JavaBeans模式。(P8)

JavaBean就是我们平常能见到的字段为private,只暴露setXxx和getXxx的一种模式,它的好处就是能避免客户端直接访问字段,从而在通过方法访问时可以随时修改字段的校验规则,添加各种前后操作等等(例如日志)。[2]

总结一下构造器,JavaBean,生成器的选择策略:

  • 当参数较少,或几乎全部参数都必选,不能(或很少)使用默认值时,使用构造器。例如一个订单类,包括订单号,商品列表,联系方式,邮寄地址,以及可选的备注,此时只需要两个构造器即可(包含备注和不包含备注)。
  • 当类为可变类,需要在实例化后经常改变参数时,使用JavaBean。例如一个人的金融账号类,其中存款,理财,信用额度等都会经常改变,且一般从数据库查询并创建对象。
  • 当类为不可变类,需要固定字段状态,且想要分段初始化,甚至根据同一模板build多个实例时,使用生成器。例如一个配置类,它可能从文件初始化对象,然后读取命令行参数来覆盖某些字段,且配置在传入对应服务类时被固定下来,即不支持热重载时。

生成器模式模拟了Python和Scala中的命名可选参数。 (P10)

至少就Python来说,命名可选参数就语法上并不像生成器那样可以使用链式调用并保存中间状态以复用,反而,它更像自动生成的多个重叠构造器。考虑下面的代码:

python
class NutritionFacts:
    def __init__(self, servingSize, servings, calories=0, fat=0, sodium=0, carbohydrate=0):
        self.servingSize = servingSize
        self.servings = servings
        self.calories = calories
        self.fat = fat
        self.sodium = sodium
        self.carbohydrate = carbohydrate

此时可以如此调用:

python
nf1 = NutritionFacts(240, 8)  # servingSize=240, servings=8, calories=0, fat=0, sodium=0, carbohydrate=0
nf2 = NutritionFacts(240, 8, 100)  # servingSize=240, servings=8, calories=100, fat=0, sodium=0, carbohydrate=0
nf3 = NutritionFacts(240, 8, fat=0, sodium=35)  # servingSize=240, servings=8, calories=0, fat=0, sodium=35, carbohydrate=0
nf4 = NutritionFacts(carbohydrate=27, servings=8, servingSize=240)  # servingSize=240, servings=8, calories=0, fat=0, sodium=0, carbohydrate=27

而Python中确实也可以实现类似Java生成器的类,但若无需保存中间状态以复用,直接使用可选参数通常是更好的选择。


java
protected abstract T self();

(P11的第一处代码)

这里为什么需要单独写一个受保护的self()函数呢?addTopping直接return this不行吗?如果你使用IntelliJ IDEA,修改代码后会发现IDEA检查出了一个类型错误:

text
必需类型: T
提供: Builder<T>

我们可以注意到,this实际上的类型为Builder<T>,但我们需要返回的是子类具体的Builder(存储在类型变量T中),而非父类,从而实现安全的链式调用。所以此处只能曲折一下,将return this推迟到知道自己具体类型的子类中进行。(实验代码链接)

条目3:利用私有构造器或枚举类型强化Singleton属性 ​

Singleton是指只能被实例化一次的类[Gamma95]。(P13)

“Singleton”通常被翻译为 “单例(类)”,而“Singleton Pattern”则通常被翻译为 “单例模式”。


第一种方式是用一个final字段作为这个公有静态的成员。(P13)

个人意见上,并不太推荐使用第一种方式实现单例模式。它最大的缺点就是对字段的暴露,这使得它难以进行Mock、未来改为懒加载或多例,以及继承上可能会有混乱。相比之下,getInstance封装会更好一些。


实现Singleton的第三种方式是声明一个只包含单个元素的枚举类型。(P14)

枚举类型虽然在安全性上几乎无懈可击,但在语义上会使人困惑。枚举的本意是“列举有限的已知子集”,而单例的本意是“全局唯一的有状态服务”,使用枚举来变相实现安全的单例会牺牲掉代码的自解释。作者主要编写需要保证绝对安全的库,预防别有用心之人的攻击,这里推荐第三种方式是情有可原的;但要注意,不要为了过度防御而牺牲代码的可读性。如果不是真的要求非常高的安全性,请仍然使用第二种方法。

另外,条目3中并没有提及懒加载下的单例模式,条目83虽然提及了延迟初始化,但也只是涉及了普通字段。Bill Pugh提供的单例实现[3]能很好的解决这个问题(甚至能推广到一般的字段上)。请看下面的代码(GitHub仓库链接):

java
public class BillPughSingleton {
    private String myField;

    private BillPughSingleton() { }

    private static class Holder {
        private static final BillPughSingleton BILL_PUGH_SINGLETON_INSTANCE = new BillPughSingleton();
    }

    public static BillPughSingleton getInstance() {
        return Holder.BILL_PUGH_SINGLETON_INSTANCE;
    }
}

这段代码利用了Java类加载器的机制,即同一个内部静态类只会加载一次。它省去了一般方法“双重检查”产生的锁开销,且代码更加简洁易懂。唯一的缺点就是为此需要额外添加一个Holder静态内部类。总之,若业务代码中需要懒加载,这种方法是十分值得被推崇的。

条目4:利用私有构造器防止类被实例化 ​

不过这个习惯用法带来了一个副作用——这样的类无法被子类化了。

条目5:优先考虑通过依赖注入来连接资源 ​

通过使用依赖注入框架(dependency injection framework),如Dagger[Dagger]、Guice[Guice]或Spring[Spring],可以完全避免这种混乱。(P16)

这里简单用AI搓了一个示例,分别保存在源码仓库的ManualDiExpandedDemo和SpringDiExpandedDemo中。下面比较一下两个的main方法。

点击展开代码

ManualDiExpandedDemo(手动管理) ​

java
public static void main(String[] args) {
    DatabaseConfig databaseConfig = new DatabaseConfig(
            "jdbc:postgresql://localhost:5432/spell",
            "app",
            "secret"
    );

    CacheConfig cacheConfig = new CacheConfig(300, 10_000);
    LanguageConfig languageConfig = new LanguageConfig("en");
    LocaleConfig localeConfig = new LocaleConfig(Locale.ENGLISH);
    SpellCheckerConfig spellCheckerConfig = new SpellCheckerConfig(10);
    ConnectionPool connectionPool = new ConnectionPool(databaseConfig);
    CredentialsProvider credentialsProvider = new CredentialsProvider();
    AppDataSource dataSource = new AppDataSource(connectionPool, credentialsProvider);
    WordMapper wordMapper = new WordMapper();
    WordRepository wordRepository = new WordRepository(dataSource, wordMapper);
    CacheClient cacheClient = new CacheClient("redis://localhost:6379");
    CacheService cacheService = new CacheService(cacheClient, cacheConfig);
    Lexicon lexicon = new Lexicon(wordRepository, cacheService, languageConfig);
    Normalizer normalizer = new Normalizer();
    Tokenizer tokenizer = new Tokenizer(normalizer, localeConfig);
    SimilarityScorer similarityScorer = new SimilarityScorer();
    SuggestionService suggestionService = new SuggestionService(lexicon, tokenizer, similarityScorer);
    AuditLogger auditLogger = new AuditLogger();
    SpellChecker spellChecker = new SpellChecker(
            lexicon,
            tokenizer,
            suggestionService,
            auditLogger,
            spellCheckerConfig
    );
    
    List<String> suggestions = spellChecker.suggest("helo wrld");
    System.out.println("拼写建议:" + suggestions);
}

SpringDiExpandedDemo(自动管理) ​

java
static void main(String[] args) {
    ConfigurableApplicationContext context =
            SpringApplication.run(SpringDiExpandedDemo.class, args);

    SpellChecker spellChecker = context.getBean(SpellChecker.class);

    List<String> suggestions = spellChecker.suggest("helo wrld");
    System.out.println("拼写建议:" + suggestions);
}

完整代码请到代码仓库查看。

条目6:避免创建不必要的对象 ​

举一个反例,考虑如下语句:

String s = new String("bikini"); // 不要这么做 (P17)

这一语句到底做了什么呢?阅读OpenJDK的代码实现:

java
/**
 * Initializes a newly created {@code String} object so that it represents
 * the same sequence of characters as the argument; in other words, the
 * newly created string is a copy of the argument string. Unless an
 * explicit copy of {@code original} is needed, use of this constructor is
 * unnecessary since Strings are immutable.
 *
 * @param  original
 *         A {@code String}
 */
@IntrinsicCandidate
public String(String original) {
    this.value = original.value;
    this.coder = original.coder;
    this.hash = original.hash;
    this.hashIsZero = original.hashIsZero;
}

正如Javadoc中所说,“除非需要original的显式副本,否则由于字符串是不可变的,使用此构造函数是没有必要的”,这个构造器基本是多余的,但当时的程序员并没有意识到这个问题,后来再改也有可能破坏部分程序的兼容性,所以就只能保留该构造器了。要注意的是,这个构造函数实现的是浅拷贝,底层数组value实际上是共享的,所以真的要进行防御性拷贝时应该用下面的方法:

java
new String(original.toCharArray());
// 或
new String(original.getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8);

一个有正当理由使用对象池的典型例子是数据库连接。(P19)

有以下几个原因来在此处使用对象池:

  • 建立物理连接很贵:TCP 握手、数据库认证、初始化会话、权限检查等;
  • 数据库能承载的连接数有限,不能每个请求都新建一个;
  • 连接用完可以归还复用,不需要反复创建销毁;
  • 连接池还能统一控制最大连接数、超时、空闲回收,避免把数据库打垮。

可以拿HikariCP举个具体的例子,这也是Spring Boot的默认数据库连接池。这里暂时不具体展开了,可以看看这篇文章[4]。

条目7:清除过期的对象引用 ​

在支持垃圾回收的语言中,内存泄漏(称之为“无意的对象保持”可能更恰当)非常隐蔽。(P20)

GC语言可以叫做“无意保持”,而非GC语言的话用“遗漏释放”比较合适。两者最终都是引向OOM的结局。

条目8:避免使用终结方法和清理方法 ​

条目9:与try-finally相比,首选try-with-resources ​

以firstLineOfFile方法为例,如果readLine调用和(代码中看不到的)close调用都抛出了异常,后者的异常会被抑制(suppressed),以便让前者正常表现出来。(P27)

这里可以尝试运行代码仓库中的TryWithResourcesTest.main,来观察StackTrace输出。在我电脑上的输出如下:

text
java.io.IOException: Exception from readLine
	at top.kgy145.ejae.c2.TryWithResourcesTest$BadBufferedReader.readLine(TryWithResourcesTest.java:16)
	at top.kgy145.ejae.c2.TryWithResourcesTest.firstLineOfFile(TryWithResourcesTest.java:27)
	at top.kgy145.ejae.c2.TryWithResourcesTest.main(TryWithResourcesTest.java:34)
	Suppressed: java.io.IOException: Exception from close
		at top.kgy145.ejae.c2.TryWithResourcesTest$BadBufferedReader.close(TryWithResourcesTest.java:21)
		at top.kgy145.ejae.c2.TryWithResourcesTest.firstLineOfFile(TryWithResourcesTest.java:26)
		... 1 more

  1. 工厂方法 - Java教程 - 廖雪峰的官方网站 ↩︎

  2. JavaBean - Java教程 - 廖雪峰的官方网站 ↩︎

  3. Bill Pugh Singleton 实现 | Baeldung | Baeldung中文网 ↩︎

  4. HikariCP 介绍 | Baeldung中文网 ↩︎

E-mail: kgy145@126.com