我的学习笔记

土猛的员外

Effective Java 3rd(Effective Java 第三版中文翻译) (6-10)

本文是根据《Effective Java 3rd》英文版翻译的,仅供自己学习用!

6. 避免创建不必要的对象

在每次需要时重用一个对象而不是创建一个新的相同功能对象通常是恰当的。重用可以更快更流行。如果对象是不可变的(条目 17),它总是可以被重用。

作为一个不应该这样做的极端例子,请考虑以下语句:

1
String s = new String("bikini");  // 千万别这么做!

语句每次执行时都会创建一个新的String实例,而这些对象的创建都不是必需的。String构造方法(“bikini”)的参数本身就是一个bikini实例,它与构造方法创建的所有对象的功能相同。如果这种用法发生在循环中,或者在频繁调用的方法中,就可以毫无必要地创建数百万个String实例。

改进后的版本如下:

1
String s = "bikini";

该版本使用单个String实例,而不是每次执行时创建一个新实例。此外,它可以保证对象运行在同一虚拟机上的任何其他代码重用,而这些代码恰好包含相同的字符串字面量[JLS,3.10.5]。

通过使用静态工厂方法(static factory methods(项目1),可以避免创建不需要的对象。例如,工厂方法Boolean.valueOf(String)比构造方法Boolean(String)更可取,后者在Java 9中被弃用。构造方法每次调用时都必须创建一个新对象,而工厂方法永远不需要这样做,在实践中也不需要。除了重用不可变对象,如果知道它们不会被修改,还可以重用可变对象。

一些对象的创建比其他对象的创建要昂贵得多。 如果要重复使用这样一个“昂贵的对象”,建议将其缓存起来以便重复使用。 不幸的是,当创建这样一个对象时并不总是很直观明显的。 假设你想写一个方法来确定一个字符串是否是一个有效的罗马数字。 以下是使用正则表达式完成此操作时最简单方法:

1
2
3
4
5
// Performance can be greatly improved!
static boolean isRomanNumeral(String s) {
return s.matches("^(?=.)M*(C[MD]|D?C{0,3})"
+ "(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");
}

这个实现的问题在于它依赖于String.matches方法。 虽然String.matches是检查字符串是否与正则表达式匹配的最简单方法,但它不适合在性能临界的情况下重复使用。 问题是它在内部为正则表达式创建一个Pattern实例,并且只使用它一次,之后它就有资格进行垃圾收集。 创建Pattern实例是昂贵的,因为它需要将正则表达式编译成有限状态机(finite state machine)。

为了提高性能,作为类初始化的一部分,将正则表达式显式编译为一个Pattern实例(不可变),缓存它,并在isRomanNumeral方法的每个调用中重复使用相同的实例:

1
2
3
4
5
6
7
8
9
10
// Reusing expensive object for improved performance
public class RomanNumerals {
private static final Pattern ROMAN = Pattern.compile(
"^(?=.)M*(C[MD]|D?C{0,3})"
+ "(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");

static boolean isRomanNumeral(String s) {
return ROMAN.matcher(s).matches();
}
}

如果经常调用,isRomanNumeral的改进版本的性能会显著提升。 在我的机器上,原始版本在输入8个字符的字符串上需要1.1微秒,而改进的版本则需要0.17微秒,速度提高了6.5倍。 性能上不仅有所改善,而且更明确清晰了。 为不可见的Pattern实例创建静态final修饰的属性,并允许给它一个名字,这个名字比正则表达式本身更具可读性。

如果包含isRomanNumeral方法的改进版本的类被初始化,但该方法从未被调用,则ROMAN属性则没必要初始化。 在第一次调用isRomanNumeral方法时,可以通过延迟初始化( lazily initializing)属性(条目 83)来排除初始化,但一般不建议这样做。 延迟初始化常常会导致实现复杂化,而性能没有可衡量的改进(条目 67)。

当一个对象是不可变的时,很明显它可以被安全地重用,但是在其他情况下,它远没有那么明显,甚至是违反直觉的。考虑适配器(adapters)的情况[Gamma95],也称为视图(views)。一个适配器是一个对象,它委托一个支持对象(backing object),提供一个可替代的接口。由于适配器没有超出其支持对象的状态,因此不需要为给定对象创建多个给定适配器的实例。

例如,Map接口的keySet方法返回Map对象的Set视图,包含Map中的所有key。 天真地说,似乎每次调用keySet都必须创建一个新的Set实例,但是对给定Map对象的keySet的每次调用都返回相同的Set实例。 尽管返回的Set实例通常是可变的,但是所有返回的对象在功能上都是相同的:当其中一个返回的对象发生变化时,所有其他对象也都变化,因为它们全部由相同的Map实例支持。 虽然创建keySet视图对象的多个实例基本上是无害的,但这是没有必要的,也没有任何好处。

另一种创建不必要的对象的方法是自动装箱(autoboxing),它允许程序员混用基本类型和包装的基本类型,根据需要自动装箱和拆箱。 自动装箱模糊不清,但不会消除基本类型和装箱基本类型之间的区别。 有微妙的语义区别和不那么细微的性能差异(条目 61)。 考虑下面的方法,它计算所有正整数的总和。 要做到这一点,程序必须使用long类型,因为int类型不足以保存所有正整数的总和:

1
2
3
4
5
6
7
// Hideously slow! Can you spot the object creation?
private static long sum() {
Long sum = 0L;
for (long i = 0; i <= Integer.MAX_VALUE; i++)
sum += i;
return sum;
}

这个程序的结果是正确的,但由于写错了一个字符,运行的结果要比实际慢很多。变量sum被声明成了Long而不是long,这意味着程序构造了大约231不必要的Long实例(大约每次往Long类型的 sum变量中增加一个long类型构造的实例),把sum变量的类型由Long改为long,在我的机器上运行时间从6.3秒降低到0.59秒。这个教训很明显:优先使用基本类型而不是装箱的基本类型,也要注意无意识的自动装箱

这个条目不应该被误解为暗示对象创建是昂贵的,应该避免创建对象。 相反,使用构造方法创建和回收小的对象是非常廉价,构造方法只会做很少的显示工作,,尤其是在现代JVM实现上。 创建额外的对象以增强程序的清晰度,简单性或功能性通常是件好事。

相反,除非池中的对象非常重量级,否则通过维护自己的对象池来避免对象创建是一个坏主意。对象池的典型例子就是数据库连接。建立连接的成本非常高,因此重用这些对象是有意义的。但是,一般来说,维护自己的对象池会使代码混乱,增加内存占用,并损害性能。现代JVM实现具有高度优化的垃圾收集器,它们在轻量级对象上轻松胜过此类对象池。

这个条目的对应点是针对条目 50的防御性复制(defensive copying)。 目前的条目说:“当你应该重用一个现有的对象时,不要创建一个新的对象”,而条目 50说:“不要重复使用现有的对象,当你应该创建一个新的对象时。”请注意,重用防御性复制所要求的对象所付出的代价,要远远大于不必要地创建重复的对象。 未能在需要的情况下防御性复制会导致潜在的错误和安全漏洞;而不必要地创建对象只会影响程序的风格和性能。

7. 消除过期的对象引用

如果你从使用手动内存管理的语言(如C或c++)切换到像Java这样的带有垃圾收集机制的语言,那么作为程序员的工作就会变得容易多了,因为你的对象在使用完毕以后就自动回收了。当你第一次体验它的时候,它就像魔法一样。这很容易让人觉得你不需要考虑内存管理,但这并不完全正确。

考虑以下简单的堆栈实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Can you spot the "memory leak"?
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;

public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}

public void push(Object e) {
ensureCapacity();
elements[size++] = e;
}

public Object pop() {
if (size == 0)
throw new EmptyStackException();
return elements[--size];
}

/**
* Ensure space for at least one more element, roughly
* doubling the capacity each time the array needs to grow.
*/
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}

这个程序没有什么明显的错误(但是对于泛型版本,请参阅条目 29)。 你可以对它进行详尽的测试,它都会成功地通过每一项测试,但有一个潜在的问题。 笼统地说,程序有一个“内存泄漏”,由于垃圾回收器的活动的增加,或内存占用的增加,静默地表现为性能下降。 在极端的情况下,这样的内存泄漏可能会导致磁盘分页( disk paging),甚至导致内存溢出(OutOfMemoryError)的失败,但是这样的故障相对较少。

那么哪里发生了内存泄漏? 如果一个栈增长后收缩,那么从栈弹出的对象不会被垃圾收集,即使使用栈的程序不再引用这些对象。 这是因为栈维护对这些对象的过期引用( obsolete references)。 过期引用简单来说就是永远不会解除的引用。 在这种情况下,元素数组“活动部分(active portion)”之外的任何引用都是过期的。 活动部分是由索引下标小于size的元素组成。

垃圾收集语言中的内存泄漏(更适当地称为无意的对象保留 unintentional object retentions)是隐蔽的。 如果无意中保留了对象引用,那么不仅这个对象排除在垃圾回收之外,而且该对象引用的任何对象也是如此。 即使只有少数对象引用被无意地保留下来,也可以阻止垃圾回收机制对许多对象的回收,这对性能产生很大的影响。

这类问题的解决方法很简单:一旦对象引用过期,将它们设置为 null。 在我们的Stack类的情景下,只要从栈中弹出,元素的引用就设置为过期。 pop方法的修正版本如下所示:

1
2
3
4
5
6
7
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // Eliminate obsolete reference
return result;
}

取消过期引用的另一个好处是,如果它们随后被错误地引用,程序立即抛出NullPointerException异常,而不是悄悄地做继续做错误的事情。尽可能快地发现程序中的错误是有好处的。

当程序员第一次被这个问题困扰时,他们可能会在程序结束后立即清空所有对象引用。这既不是必要的,也不是可取的;它不必要地搞乱了程序。清空对象引用应该是例外而不是规范。消除过期引用的最好方法是让包含引用的变量超出范围。如果在最近的作用域范围内定义每个变量(条目 57),这种自然就会出现这种情况。

那么什么时候应该清空一个引用呢?Stack类的哪个方面使它容易受到内存泄漏的影响?简单地说,它管理自己的内存。存储池(storage pool)由elements数组的元素组成(对象引用单元,而不是对象本身)。数组中活动部分的元素(如前面定义的)被分配,其余的元素都是空闲的。垃圾收集器没有办法知道这些;对于垃圾收集器来说,elements数组中的所有对象引用都同样有效。只有程序员知道数组的非活动部分不重要。程序员可以向垃圾收集器传达这样一个事实,一旦数组中的元素变成非活动的一部分,就可以手动清空这些元素的引用。

一般来说,当一个类自己管理内存时,程序员应该警惕内存泄漏问题。 每当一个元素被释放时,元素中包含的任何对象引用都应该被清除。

另一个常见的内存泄漏来源是缓存。一旦将对象引用放入缓存中,很容易忘记它的存在,并且在它变得无关紧要之后,仍然保留在缓存中。对于这个问题有几种解决方案。如果你正好想实现了一个缓存:只要在缓存之外存在对某个项(entry)的键(key)引用,那么这项就是明确有关联的,就可以用WeakHashMap来表示缓存;这些项在过期之后自动删除。记住,只有当缓存中某个项的生命周期是由外部引用到键(key)而不是值(value)决定时,WeakHashMap才有用。

更常见的情况是,缓存项有用的生命周期不太明确,随着时间的推移一些项变得越来越没有价值。在这种情况下,缓存应该偶尔清理掉已经废弃的项。这可以通过一个后台线程(也许是ScheduledThreadPoolExecutor)或将新的项添加到缓存时顺便清理。LinkedHashMap类使用它的removeEldestEntry方法实现了后一种方案。对于更复杂的缓存,可能直接需要使用java.lang.ref

第三个常见的内存泄漏来源是监听器和其他回调。如果你实现了一个API,其客户端注册回调,但是没有显式地撤销注册回调,除非采取一些操作,否则它们将会累积。确保回调是垃圾收集的一种方法是只存储弱引用(weak references),例如,仅将它们保存在WeakHashMap的键(key)中。

因为内存泄漏通常不会表现为明显的故障,所以它们可能会在系统中保持多年。 通常仅在仔细的代码检查或借助堆分析器( heap profiler)的调试工具才会被发现。 因此,学习如何预见这些问题,并防止这些问题发生,是非常值得的。

8. 避免使用Finalizer和Cleaner机制

Finalizer机制是不可预知的,往往是危险的,而且通常是不必要的。 它们的使用会导致不稳定的行为,糟糕的性能和移植性问题。 Finalizer机制有一些特殊的用途,我们稍后会在这个条目中介绍,但是通常应该避免它们。 从Java 9开始,Finalizer机制已被弃用,但仍被Java类库所使用。 Java 9中 Cleaner机制代替了Finalizer机制。 Cleaner机制不如Finalizer机制那样危险,但仍然是不可预测,运行缓慢并且通常是不必要的。

提醒C++程序员不要把Java中的Finalizer或Cleaner机制当成的C ++析构函数的等价物。 在C++中,析构函数是回收对象相关资源的正常方式,是与构造方法相对应的。 在Java中,当一个对象变得不可达时,垃圾收集器回收与对象相关联的存储空间,不需要开发人员做额外的工作。 C ++析构函数也被用来回收其他非内存资源。 在Java中,try-with-resources或try-finally块用于此目的(条目 9)。

Finalizer和Cleaner机制的一个缺点是不能保证他们能够及时执行[JLS,12.6]。 在一个对象变得无法访问时,到Finalizer和Cleaner机制开始运行时,这期间的时间是任意长的。 这意味着你永远不应该Finalizer和Cleaner机制做任何时间敏感(time-critical)的事情。 例如,依赖于Finalizer和Cleaner机制来关闭文件是严重的错误,因为打开的文件描述符是有限的资源。 如果由于系统迟迟没有运行Finalizer和Cleaner机制而导致许多文件被打开,程序可能会失败,因为它不能再打开文件了。

及时执行Finalizer和 Cleaner机制是垃圾收集算法的一个功能,这种算法在不同的实现中有很大的不同。程序的行为依赖于Finalizer和 Cleaner机制的及时执行,其行为也可能大不不同。 这样的程序完全可以在你测试的JVM上完美运行,然而在你最重要的客户的机器上可能运行就会失败。

延迟终结(finalization)不只是一个理论问题。为一个类提供一个Finalizer机制可以任意拖延它的实例的回收。一位同事调试了一个长时间运行的GUI应用程序,这个应用程序正在被一个OutOfMemoryError错误神秘地死掉。分析显示,在它死亡的时候,应用程序的Finalizer机制队列上有成千上万的图形对象正在等待被终结和回收。不幸的是,Finalizer机制线程的运行优先级低于其他应用程序线程,所以对象被回收的速度低于进入队列的速度。语言规范并不保证哪个线程执行Finalizer机制,因此除了避免使用Finalizer机制之外,没有轻便的方法来防止这类问题。在这方面, Cleaner机制比Finalizer机制要好一些,因为Java类的创建者可以控制自己cleaner机制的线程,但cleaner机制仍然在后台运行,在垃圾回收器的控制下运行,但不能保证及时清理。

Java规范不能保证Finalizer和Cleaner机制能及时运行;它甚至不能能保证它们是否会运行。当一个程序结束后,一些不可达对象上的Finalizer和Cleaner机制仍然没有运行。因此,不应该依赖于Finalizer和Cleaner机制来更新持久化状态。例如,依赖于Finalizer和Cleaner机制来释放对共享资源(如数据库)的持久锁,这是一个使整个分布式系统陷入停滞的好方法。

不要相信System.gcSystem.runFinalization方法。 他们可能会增加Finalizer和Cleaner机制被执行的几率,但不能保证一定会执行。 曾经声称做出这种保证的两个方法:System.runFinalizersOnExit和它的孪生兄弟Runtime.runFinalizersOnExit,包含致命的缺陷,并已被弃用了几十年[ThreadStop]。

Finalizer机制的另一个问题是在执行Finalizer机制过程中,未捕获的异常会被忽略,并且该对象的Finalizer机制也会终止 [JLS, 12.6]。未捕获的异常会使其他对象陷入一种损坏的状态(corrupt state)。如果另一个线程试图使用这样一个损坏的对象,可能会导致任意不确定的行为。通常情况下,未捕获的异常将终止线程并打印堆栈跟踪( stacktrace),但如果发生在Finalizer机制中,则不会发出警告。Cleaner机制没有这个问题,因为使用Cleaner机制的类库可以控制其线程。

使用finalizer和cleaner机制会导致严重的性能损失。 在我的机器上,创建一个简单的AutoCloseable对象,使用try-with-resources关闭它,并让垃圾回收器回收它的时间大约是12纳秒。 使用finalizer机制,而时间增加到550纳秒。 换句话说,使用finalizer机制创建和销毁对象的速度要慢50倍。 这主要是因为finalizer机制会阻碍有效的垃圾收集。 如果使用它们来清理类的所有实例(在我的机器上的每个实例大约是500纳秒),那么cleaner机制的速度与finalizer机制的速度相当,但是如果仅将它们用作安全网( safety net),则cleaner机制要快得多,如下所述。 在这种环境下,创建,清理和销毁一个对象在我的机器上需要大约66纳秒,这意味着如果你不使用安全网的话,需要支付5倍(而不是50倍)的保险。

finalizer机制有一个严重的安全问题:它们会打开你的类来进行finalizer机制攻击。finalizer机制攻击的想法很简单:如果一个异常是从构造方法或它的序列化中抛出的——readObject和readResolve方法(第12章)——恶意子类的finalizer机制可以运行在本应该“中途夭折(died on the vine)”的部分构造对象上。finalizer机制可以在静态字属性记录对对象的引用,防止其被垃圾收集。一旦记录了有缺陷的对象,就可以简单地调用该对象上的任意方法,而这些方法本来就不应该允许存在。从构造方法中抛出异常应该足以防止对象出现;而在finalizer机制存在下,则不是。这样的攻击会带来可怕的后果。Final类不受finalizer机制攻击的影响,因为没有人可以编写一个final类的恶意子类。为了保护非final类不受finalizer机制攻击,编写一个final的finalize方法,它什么都不做。

那么,你应该怎样做呢?为对象封装需要结束的资源(如文件或线程),而不是为该类编写Finalizer和Cleaner机制?让你的类实现AutoCloseable接口即可,并要求客户在在不再需要时调用每个实例close方法,通常使用try-with-resources确保终止,即使面对有异常抛出情况(条目 9)。一个值得一提的细节是实例必须跟踪是否已经关闭:close方法必须记录在对象里不再有效的属性,其他方法必须检查该属性,如果在对象关闭后调用它们,则抛出IllegalStateException异常。

那么,Finalizer和Cleaner机制有什么好处呢?它们可能有两个合法用途。一个是作为一个安全网(safety net),以防资源的拥有者忽略了它的close方法。虽然不能保证Finalizer和Cleaner机制会迅速运行(或者根本就没有运行),最好是把资源释放晚点出来,也要好过客户端没有这样做。如果你正在考虑编写这样的安全网Finalizer机制,请仔细考虑一下这样保护是否值得付出对应的代价。一些Java库类,如FileInputStreamFileOutputStreamThreadPoolExecutorjava.sql.Connection,都有作为安全网的Finalizer机制。

第二种合理使用Cleaner机制的方法与本地对等类(native peers)有关。本地对等类是一个由普通对象委托的本地(非Java)对象。由于本地对等类不是普通的 Java对象,所以垃圾收集器并不知道它,当它的Java对等对象被回收时,本地对等类也不会回收。假设性能是可以接受的,并且本地对等类没有关键的资源,那么Finalizer和Cleaner机制可能是这项任务的合适的工具。但如果性能是不可接受的,或者本地对等类持有必须迅速回收的资源,那么类应该有一个close方法,正如前面所述。

Cleaner机制使用起来有点棘手。下面是演示该功能的一个简单的Room类。假设Room对象必须在被回收前清理干净。Room类实现AutoCloseable接口;它的自动清理安全网使用的是一个Cleaner机制,这仅仅是一个实现细节。与Finalizer机制不同,Cleaner机制不污染一个类的公共API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// An autocloseable class using a cleaner as a safety net
public class Room implements AutoCloseable {
private static final Cleaner cleaner = Cleaner.create();

// Resource that requires cleaning. Must not refer to Room!
private static class State implements Runnable {
int numJunkPiles; // Number of junk piles in this room

State(int numJunkPiles) {
this.numJunkPiles = numJunkPiles;
}

// Invoked by close method or cleaner
@Override
public void run() {
System.out.println("Cleaning room");
numJunkPiles = 0;
}
}

// The state of this room, shared with our cleanable
private final State state;

// Our cleanable. Cleans the room when it’s eligible for gc
private final Cleaner.Cleanable cleanable;

public Room(int numJunkPiles) {
state = new State(numJunkPiles);
cleanable = cleaner.register(this, state);
}

@Override
public void close() {
cleanable.clean();
}
}

静态内部State类拥有Cleaner机制清理房间所需的资源。 在这里,它仅仅包含numJunkPiles属性,它代表混乱房间的数量。 更实际地说,它可能是一个final修饰的long类型的指向本地对等类的指针。 State类实现了Runnable接口,其run方法最多只能调用一次,只能被我们在Room构造方法中用Cleaner机制注册State实例时得到的Cleanable调用。 对run方法的调用通过以下两种方法触发:通常,通过调用Roomclose方法内调用Cleanableclean方法来触发。 如果在Room实例有资格进行垃圾回收的时候客户端没有调用close方法,那么Cleaner机制将(希望)调用Staterun方法。

一个State实例不引用它的Room实例是非常重要的。如果它引用了,则创建了一个循环,阻止了Room实例成为垃圾收集的资格(以及自动清除)。因此,State必须是静态的嵌内部类,因为非静态内部类包含对其宿主类的实例的引用(条目 24)。同样,使用lambda表达式也是不明智的,因为它们很容易获取对宿主类对象的引用。

就像我们之前说的,Room的Cleaner机制仅仅被用作一个安全网。如果客户将所有Room的实例放在try-with-resource块中,则永远不需要自动清理。行为良好的客户端如下所示:

1
2
3
4
5
6
7
public class Adult {
public static void main(String[] args) {
try (Room myRoom = new Room(7)) {
System.out.println("Goodbye");
}
}
}

正如你所预料的,运行Adult程序会打印Goodbye字符串,随后打印Cleaning room字符串。但是如果时不合规矩的程序,它从来不清理它的房间会是什么样的?

1
2
3
4
5
6
public class Teenager {
public static void main(String[] args) {
new Room(99);
System.out.println("Peace out");
}
}

你可能期望它打印出Peace out,然后打印Cleaning room字符串,但在我的机器上,它从不打印Cleaning room字符串;仅仅是程序退出了。 这是我们之前谈到的不可预见性。 Cleaner机制的规范说:“System.exit方法期间的清理行为是特定于实现的。 不保证清理行为是否被调用。”虽然规范没有说明,但对于正常的程序退出也是如此。 在我的机器上,将System.gc()方法添加到Teenager类的main方法足以让程序退出之前打印Cleaning room,但不能保证在你的机器上会看到相同的行为。

总之,除了作为一个安全网或者终止非关键的本地资源,不要使用Cleaner机制,或者是在Java 9发布之前的finalizers机制。即使是这样,也要当心不确定性和性能影响。

9. 使用try-with-resources语句替代try-finally语句

Java类库中包含许多必须通过调用close方法手动关闭的资源。 比如InputStreamOutputStreamjava.sql.Connection。 客户经常忽视关闭资源,其性能结果可想而知。 尽管这些资源中有很多使用finalizer机制作为安全网,但finalizer机制却不能很好地工作(条目 8)。

从以往来看,try-finally语句是保证资源正确关闭的最佳方式,即使是在程序抛出异常或返回的情况下:

1
2
3
4
5
6
7
8
9
// try-finally - No longer the best way to close resources!
static String firstLineOfFile(String path) throws IOException {
BufferedReader br = new BufferedReader(new FileReader(path));
try {
return br.readLine();
} finally {
br.close();
}
}

这可能看起来并不坏,但是当添加第二个资源时,情况会变得更糟:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// try-finally is ugly when used with more than one resource!
static void copy(String src, String dst) throws IOException {
InputStream in = new FileInputStream(src);
try {
OutputStream out = new FileOutputStream(dst);
try {
byte[] buf = new byte[BUFFER_SIZE];
int n;
while ((n = in.read(buf)) >= 0)
out.write(buf, 0, n);
} finally {
out.close();
}
} finally {
in.close();
}
}

这可能很难相信,但即使是优秀的程序员,大多数时候也会犯错误。首先,我在Java Puzzlers[Bloch05]的第88页上弄错了,多年来没有人注意到。事实上,2007年Java类库中使用close方法的三分之二都是错误的。

即使是用try-finally语句关闭资源的正确代码,如前面两个代码示例所示,也有一个微妙的缺陷。 try-with-resources块和finally块中的代码都可以抛出异常。 例如,在firstLineOfFile方法中,由于底层物理设备发生故障,对readLine方法的调用可能会引发异常,并且由于相同的原因,调用close方法可能会失败。 在这种情况下,第二个异常完全冲掉了第一个异常。 在异常堆栈跟踪中没有第一个异常的记录,这可能使实际系统中的调试非常复杂——通常这是你想要诊断问题的第一个异常。 虽然可以编写代码来抑制第二个异常,但是实际上没有人这样做,因为它太冗长了。

当Java 7引入了try-with-resources语句时,所有这些问题一下子都得到了解决[JLS,14.20.3]。要使用这个构造,资源必须实现 AutoCloseable接口,该接口由一个返回为voidclose组成。Java类库和第三方类库中的许多类和接口现在都实现或继承了AutoCloseable接口。如果你编写的类表示必须关闭的资源,那么这个类也应该实现AutoCloseable接口。

以下是我们的第一个使用try-with-resources的示例:

1
2
3
4
5
6
7
// try-with-resources - the the best way to close resources!
static String firstLineOfFile(String path) throws IOException {
try (BufferedReader br = new BufferedReader(
new FileReader(path))) {
return br.readLine();
}
}

以下是我们的第二个使用try-with-resources的示例:

1
2
3
4
5
6
7
8
9
10
// try-with-resources on multiple resources - short and sweet
static void copy(String src, String dst) throws IOException {
try (InputStream in = new FileInputStream(src);
OutputStream out = new FileOutputStream(dst)) {
byte[] buf = new byte[BUFFER_SIZE];
int n;
while ((n = in.read(buf)) >= 0)
out.write(buf, 0, n);
}
}

不仅 try-with-resources版本比原始版本更精简,更好的可读性,而且它们提供了更好的诊断。 考虑firstLineOfFile方法。 如果调用readLine和(不可见)close方法都抛出异常,则后一个异常将被抑制(suppressed),而不是前者。 事实上,为了保留你真正想看到的异常,可能会抑制多个异常。 这些抑制的异常没有呗被抛弃, 而是打印在堆栈跟踪中,并标注为被抑制了。 你也可以使用getSuppressed方法以编程方式访问它们,该方法在Java 7中已添加到的Throwable中。

可以在 try-with-resources语句中添加catch子句,就像在常规的try-finally语句中一样。这允许你处理异常,而不会在另一层嵌套中污染代码。作为一个稍微有些做作的例子,这里有一个版本的firstLineOfFile方法,它不会抛出异常,但是如果它不能打开或读取文件,则返回默认值:

1
2
3
4
5
6
7
8
9
// try-with-resources with a catch clause
static String firstLineOfFile(String path, String defaultVal) {
try (BufferedReader br = new BufferedReader(
new FileReader(path))) {
return br.readLine();
} catch (IOException e) {
return defaultVal;
}
}

结论明确:在处理必须关闭的资源时,使用try-with-resources语句替代try-finally语句。 生成的代码更简洁,更清晰,并且生成的异常更有用。 try-with-resources语句在编写必须关闭资源的代码时会更容易,也不会出错,而使用try-finally语句实际上是不可能的。

10. 重写equals方法时遵守通用约定

虽然Object是一个具体的类,但它主要是为继承而设计的。它的所有非 final方法(equals、hashCode、toString、clone和finalize)都有清晰的通用约定( general contracts),因为它们被设计为被子类重写。任何类都有义务重写这些方法,以遵从他们的通用约定;如果不这样做,将会阻止其他依赖于约定的类(例如HashMap和HashSet)与此类一起正常工作。

本章论述何时以及如何重写Object类的非final的方法。这一章省略了finalize方法,因为它在条目 8中进行了讨论。Comparable.compareTo方法虽然不是Object中的方法,因为具有很多的相似性,所以也在这里讨论。

重写equals方法看起来很简单,但是有很多方式会导致重写出错,其结果可能是可怕的。避免此问题的最简单方法不是覆盖equals方法,在这种情况下,类的每个实例只与自身相等。如果满足以下任一下条件,则说明是正确的做法:

  • 每个类的实例都是固有唯一的。 对于像Thread这样代表活动实体而不是值的类来说,这是正确的。 Object提供的equals实现对这些类完全是正确的行为。

  • 类不需要提供一个“逻辑相等(logical equality)”的测试功能。例如java.util.regex.Pattern可以重写equals 方法检查两个是否代表完全相同的正则表达式Pattern实例,但是设计者并不认为客户需要或希望使用此功能。在这种情况下,从Object继承的equals实现是最合适的。

  • 父类已经重写了equals方法,则父类行为完全适合于该子类。例如,大多数Set从AbstractSet继承了equals实现、List从AbstractList继承了equals实现,Map从AbstractMap的Map继承了equals实现。

  • 类是私有的或包级私有的,可以确定它的equals方法永远不会被调用。如果你非常厌恶风险,可以重写equals方法,以确保不会被意外调用:

    1
    2
    3
    @Override public boolean equals(Object o) {
    throw new AssertionError(); // Method is never called
    }

那什么时候需要重写 equals 方法呢?如果一个类包含一个逻辑相等( logical equality)的概念,此概念有别于对象标识(object identity),而且父类还没有重写过equals 方法。这通常用在值类( value classes)的情况。值类只是一个表示值的类,例如Integer或String类。程序员使用equals方法比较值对象的引用,期望发现它们在逻辑上是否相等,而不是引用相同的对象。重写 equals方法不仅可以满足程序员的期望,它还支持重写过equals 的实例作为Map 的键(key),或者 Set 里的元素,以满足预期和期望的行为。

一种不需要equals方法重写的值类是使用实例控制(instance control)(条目 1)的类,以确保每个值至多存在一个对象。 枚举类型(条目 34)属于这个类别。 对于这些类,逻辑相等与对象标识是一样的,所以Object的equals方法作用逻辑equals方法。

当你重写equals方法时,必须遵守它的通用约定。Object的规范如下: equals方法实现了一个等价关系(equivalence relation)。它有以下这些属性: •自反性:对于任何非空引用x,x.equals(x)必须返回true。 •对称性:对于任何非空引用x和y,如果且仅当y.equals(x)返回true时x.equals(y)必须返回true。 •传递性:对于任何非空引用x、y、z,如果x.equals(y)返回true,y.equals(z)返回true,则x.equals(z)必须返回true。 •一致性:对于任何非空引用x和y,如果在equals比较中使用的信息没有修改,则x.equals(y)的多次调用必须始终返回true或始终返回false。 •对于任何非空引用x,x.equals(null)必须返回false。

除非你喜欢数学,否则这看起来有点吓人,但不要忽略它!如果一旦违反了它,很可能会发现你的程序运行异常或崩溃,并且很难确定失败的根源。套用约翰·多恩(John Donne)的说法,没有哪个类是孤立存在的。一个类的实例常常被传递给另一个类的实例。许多类,包括所有的集合类,都依赖于传递给它们遵守equals约定的对象。

既然已经意识到违反equals约定的危险,让我们详细地讨论一下这个约定。好消息是,表面上看,这并不是很复杂。一旦你理解了,就不难遵守这一约定。

那么什么是等价关系? 笼统地说,它是一个运算符,它将一组元素划分为彼此元素相等的子集。 这些子集被称为等价类(equivalence classes)。 为了使equals方法有用,每个等价类中的所有元素必须从用户的角度来说是可以互换(interchangeable)的。 现在让我们依次看下这个五个要求:

自反性(Reflexivity)——第一个要求只是说一个对象必须与自身相等。 很难想象无意中违反了这个规定。 如果你违反了它,然后把类的实例添加到一个集合中,那么contains方法可能会说集合中没有包含刚添加的实例。

对称性(Symmetry)——第二个要求是,任何两个对象必须在是否相等的问题上达成一致。与第一个要求不同的是,我们不难想象在无意中违反了这一要求。例如,考虑下面的类,它实现了不区分大小写的字符串。字符串被toString保存,但在equals比较中被忽略:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.util.Objects;

public final class CaseInsensitiveString {
private final String s;

public CaseInsensitiveString(String s) {
this.s = Objects.requireNonNull(s);
}

// Broken - violates symmetry!
@Override
public boolean equals(Object o) {
if (o instanceof CaseInsensitiveString)
return s.equalsIgnoreCase(
((CaseInsensitiveString) o).s);
if (o instanceof String) // One-way interoperability!
return s.equalsIgnoreCase((String) o);
return false;
}
...// Remainder omitted
}

上面类中的 equals 试图与正常的字符串进行操作,假设我们有一个不区分大小写的字符串和一个正常的字符串:

1
2
3
4
5
CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String s = "polish”;

System.out.println(cis.equals(s)); // true
System.out.println(s.equals(cis)); // false

正如所料,cis.equals(s)返回true。 问题是,尽管CaseInsensitiveString类中的equals方法知道正常字符串,但String类中的equals方法却忽略了不区分大小写的字符串。 因此,s.equals(cis)返回false,明显违反对称性。 假设把一个不区分大小写的字符串放入一个集合中:

1
2
List<CaseInsensitiveString> list = new ArrayList<>();
list.add(cis);

list.contains(s)返回了什么?谁知道呢?在当前的OpenJDK实现中,它会返回false,但这只是一个实现构件。在另一个实现中,它可以很容易地返回true或抛出运行时异常。一旦违反了equals约定,就不知道其他对象在面对你的对象时会如何表现了。

要消除这个问题,只需删除equals方法中与String类相互操作的恶意尝试。这样做之后,可以将该方法重构为单个返回语句:

1
2
3
4
5
@Override
public boolean equals(Object o) {
return o instanceof CaseInsensitiveString &&
((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}

传递性(Transitivity)——equals 约定的第三个要求是,如果第一个对象等于第二个对象,第二个对象等于第三个对象,那么第一个对象必须等于第三个对象。同样,也不难想象,无意中违反了这一要求。考虑子类的情况, 将新值组件( value component)添加到其父类中。换句话说,子类添加了一个信息,它影响了equals方法比较。让我们从一个简单不可变的二维整数类型Point类开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Point {
private final int x;
private final int y;

public Point(int x, int y) {
this.x = x;
this.y = y;
}

@Override
public boolean equals(Object o) {
if (!(o instanceof Point))
return false;
Point p = (Point) o;
return p.x == x && p.y == y;
}

... // Remainder omitted
}

假设想继承这个类,将表示颜色的Color类添加到Point类中:

1
2
3
4
5
6
7
8
9
10
public class ColorPoint extends Point {
private final Color color;

public ColorPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}

... // Remainder omitted
}

equals方法应该是什么样子?如果完全忽略,则实现是从Point类上继承的,颜色信息在equals方法比较中被忽略。虽然这并不违反equals约定,但这显然是不可接受的。假设你写了一个equals方法,它只在它的参数是另一个具有相同位置和颜色的ColorPoint实例时返回true:

1
2
3
4
5
6
// Broken - violates symmetry!
@Override public boolean equals(Object o) {
if (!(o instanceof ColorPoint))
return false;
return super.equals(o) && ((ColorPoint) o).color == color;
}

当你比较Point对象和ColorPoint对象时,可以会得到不同的结果,反之亦然。前者的比较忽略了颜色属性,而后者的比较会一直返回 false,因为参数的类型是错误的。为了让问题更加具体,我们创建一个Point对象和ColorPoint对象:

1
2
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);

p.equals(cp)返回 true,但是 cp.equals(p)返回 false。你可能想使用ColorPoint.equals 通过混合比较的方式来解决这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public boolean equals(Object o) {
if (!(o instanceof Point))
return false;

// If o is a normal Point, do a color-blind comparison
if (!(o instanceof ColorPoint))
return o.equals(this);

// o is a ColorPoint; do a full comparison
return super.equals(o) && ((ColorPoint) o).color == color;
}

这种方法确实提供了对称性,但是丧失了传递性:

1
2
3
ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);

现在,p1.equals(p2)p2.equals(p3) 返回了 true,但是p1.equals(p3)却返回了 false,很明显违背了传递性的要求。前两个比较都是不考虑颜色信息的,而第三个比较时却包含颜色信息。

此外,这种方法可能导致无限递归:假设有两个Point的子类,比如ColorPoint和SmellPoint,每个都有这种equals方法。 然后调用myColorPoint.equals(mySmellPoint)将抛出一个StackOverflowError异常。

那么解决方案是什么? 事实证明,这是面向对象语言中关于等价关系的一个基本问题。 除非您愿意放弃面向对象抽象的好处,否则无法继承可实例化的类,并在保留 equals 约定的同时添加一个值组件。

你可能听说过,可以继承一个可实例化的类并添加一个值组件,同时通过在equals方法中使用一个getClass测试代替instanceof测试来保留equals约定:

1
2
3
4
5
6
7
@Override
public boolean equals(Object o) {
if (o == null || o.getClass() != getClass())
return false;
Point p = (Point) o;
return p.x == x && p.y == y;
}

只有当对象具有相同的实现类时,才会产生相同的效果。这看起来可能不是那么糟糕,但是结果是不可接受的:一个Point类子类的实例仍然是一个Point的实例,它仍然需要作为一个Point来运行,但是如果你采用这个方法,就会失败!假设我们要写一个方法来判断一个Point 对象是否在unitCircle集合中。我们可以这样做:

1
2
3
4
5
6
7
private static final Set<Point> unitCircle = Set.of(
new Point( 1, 0), new Point( 0, 1),
new Point(-1, 0), new Point( 0, -1));

public static boolean onUnitCircle(Point p) {
return unitCircle.contains(p);
}

虽然这可能不是实现功能的最快方法,但它可以正常工作。假设以一种不添加值组件的简单方式继承 Point 类,比如让它的构造方法跟踪记录创建了多少实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class CounterPoint extends Point {
private static final AtomicInteger counter =
new AtomicInteger();

public CounterPoint(int x, int y) {
super(x, y);
counter.incrementAndGet();
}

public static int numberCreated() {
return counter.get();
}
}

里氏替代原则( Liskov substitution principle)指出,任何类型的重要属性都应该适用于所有的子类型,因此任何为这种类型编写的方法都应该在其子类上同样适用[Liskov87]。 这是我们之前声明的一个正式陈述,即Point的子类(如CounterPoint)仍然是一个Point,必须作为一个Point类来看待。 但是,假设我们将一个CounterPoint对象传递给onUnitCircle方法。 如果Point类使用基于getClass的equals方法,则无论CounterPoint实例的x和y坐标如何,onUnitCircle方法都将返回false。 这是因为大多数集合(包括onUnitCircle方法使用的HashSet)都使用equals方法来测试是否包含元素,并且CounterPoint实例并不等于任何Point实例。 但是,如果在Point上使用了适当的基于instanceof的equals方法,则在使用CounterPoint实例呈现时,同样的onUnitCircle方法可以正常工作。

虽然没有令人满意的方法来继承一个可实例化的类并添加一个值组件,但是有一个很好的变通方法:按照条目18的建议,“优先使用组合而不是继承”。取代继承Point类的ColorPoint类,可以在ColorPoint类中定义一个私有Point属性,和一个公共的试图(view)(条目6)方法,用来返回具有相同位置的ColorPoint对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Adds a value component without violating the equals contract
public class ColorPoint {
private final Point point;
private final Color color;

public ColorPoint(int x, int y, Color color) {
point = new Point(x, y);
this.color = Objects.requireNonNull(color);
}

/**
* Returns the point-view of this color point.
*/
public Point asPoint() {
return point;
}

@Override public boolean equals(Object o) {
if (!(o instanceof ColorPoint))
return false;
ColorPoint cp = (ColorPoint) o;
return cp.point.equals(point) && cp.color.equals(color);
}

... // Remainder omitted
}

Java平台类库中有一些类可以继承可实例化的类并添加一个值组件。 例如,java.sql.Timestamp继承了java.util.Date并添加了一个nanoseconds字段。 Timestamp的等价equals确实违反了对称性,并且如果Timestamp和Date对象在同一个集合中使用,或者以其他方式混合使用,则可能导致不稳定的行为。 Timestamp类有一个免责声明,告诫程序员不要混用Timestamp和Date。 虽然只要将它们分开使用就不会遇到麻烦,但没有什么可以阻止你将它们混合在一起,并且由此产生的错误可能很难调试。 Timestamp类的这种行为是一个错误,不应该被仿效。

你可以将值组件添加到抽象类的子类中,而不会违反equals约定。这对于通过遵循第23个条目中“优先考虑类层级(class hierarchies)来代替标记类(tagged classes)”中的建议而获得的类层级,是非常重要的。例如,可以有一个没有值组件的抽象类Shape,子类Circle有一个radius属性,另一个子类Rectangle包含length和width属性 。 只要不直接创建父类实例,就不会出现前面所示的问题。

一致性——equals 约定的第四个要求是,如果两个对象是相等的,除非一个(或两个)对象被修改了, 那么它们必须始终保持相等。 换句话说,可变对象可以在不同时期可以与不同的对象相等,而不可变对象则不会。 当你写一个类时,要认真思考它是否应该设计为不可变的(条目 17)。 如果你认为应该这样做,那么确保你的equals方法强制执行这样的限制:相等的对象永远相等,不相等的对象永远都不会相等。

不管一个类是不是不可变的,都不要写一个依赖于不可靠资源的equals方法。 如果违反这一禁令,满足一致性要求是非常困难的。 例如,java.net.URL类中的equals方法依赖于与URL关联的主机的IP地址的比较。 将主机名转换为IP地址可能需要访问网络,并且不能保证随着时间的推移会产生相同的结果。 这可能会导致URL类的equals方法违反equals 约定,并在实践中造成问题。 URL类的equals方法的行为是一个很大的错误,不应该被效仿。 不幸的是,由于兼容性的要求,它不能改变。 为了避免这种问题,equals方法应该只对内存驻留对象执行确定性计算。

非空性(Non-nullity)——最后equals 约定的要求没有官方的名称,所以我冒昧地称之为“非空性”。意思是说说所有的对象都必须不等于 null。虽然很难想象在调用 o.equals(null)的响应中意外地返回true,但不难想象不小心抛出NullPointerException异常的情况。通用的约定禁止抛出这样的异常。许多类中的 equals方法都会明确阻止对象为null的情况:

1
2
3
4
5
@Override public boolean equals(Object o) {
if (o == null)
return false;
...
}

这个判断是不必要的。 为了测试它的参数是否相等,equals方法必须首先将其参数转换为合适类型,以便调用访问器或允许访问的属性。 在执行类型转换之前,该方法必须使用instanceof运算符来检查其参数是否是正确的类型:

1
2
3
4
5
6
@Override public boolean equals(Object o) {
if (!(o instanceof MyType))
return false;
MyType mt = (MyType) o;
...
}

如果此类型检查漏掉,并且equals方法传递了错误类型的参数,那么equals方法将抛出ClassCastException异常,这违反了equals约定。 但是,如果第一个操作数为 null,则指定instanceof运算符返回false,而不管第二个操作数中出现何种类型[JLS,15.20.2]。 因此,如果传入null,类型检查将返回false,因此不需要 明确的 null检查。

综合起来,以下是编写高质量equals方法的配方(recipe):

  1. 使用= =运算符检查参数是否为该对象的引用。如果是,返回true。这只是一种性能优化,但是如果这种比较可能很昂贵的话,那就值得去做。
  2. 使用instanceof运算符来检查参数是否具有正确的类型。 如果不是,则返回false。 通常,正确的类型是equals方法所在的那个类。 有时候,改类实现了一些接口。 如果类实现了一个接口,该接口可以改进 equals约定以允许实现接口的类进行比较,那么使用接口。 集合接口(如Set,List,Map和Map.Entry)具有此特性。
  3. 参数转换为正确的类型。因为转换操作在instanceof中已经处理过,所以它肯定会成功。
  4. 对于类中的每个“重要”的属性,请检查该参数属性是否与该对象对应的属性相匹配。如果所有这些测试成功,返回true,否则返回false。如果步骤2中的类型是一个接口,那么必须通过接口方法访问参数的属性;如果类型是类,则可以直接访问属性,这取决于属性的访问权限。

对于类型为非float或double的基本类型,使用= =运算符进行比较;对于对象引用属性,递归地调用equals方法;对于float 基本类型的属性,使用静态Float.compare(float, float)方法;对于double 基本类型的属性,使用Double.compare(double, double)方法。由于存在Float.NaN-0.0f和类似的double类型的值,所以需要对float和double属性进行特殊的处理;有关详细信息,请参阅JLS 15.21.1或Float.equals方法的详细文档。 虽然你可以使用静态方法Float.equals和Double.equals方法对float和double基本类型的属性进行比较,这会导致每次比较时发生自动装箱,引发非常差的性能。 对于数组属性,将这些准则应用于每个元素。 如果数组属性中的每个元素都很重要,请使用其中一个重载的Arrays.equals方法。

某些对象引用的属性可能合法地包含null。 为避免出现NullPointerException异常,请使用静态方法 Objects.equals(Object, Object)检查这些属性是否相等。

对于一些类,例如上的CaseInsensitiveString类,属性比较相对于简单的相等性测试要复杂得多。在这种情况下,你想要保存属性的一个规范形式( canonical form),这样 equals 方法就可以基于这个规范形式去做开销很小的精确比较,来取代开销很大的非标准比较。这种方式其实最适合不可变类(条目 17)。一旦对象发生改变,一定要确保把对应的规范形式更新到最新。

equals方法的性能可能受到属性比较顺序的影响。 为了获得最佳性能,你应该首先比较最可能不同的属性,开销比较小的属性,或者最好是两者都满足(derived fields)。 你不要比较不属于对象逻辑状态的属性,例如用于同步操作的lock 属性。 不需要比较可以从“重要属性”计算出来的派生属性,但是这样做可以提高equals方法的性能。 如果派生属性相当于对整个对象的摘要描述,比较这个属性将节省在比较失败时再去比较实际数据的开销。 例如,假设有一个Polygon类,并缓存该区域。 如果两个多边形的面积不相等,则不必费心比较它们的边和顶点。

当你完成编写完equals方法时,问你自己三个问题:它是对称的吗?它是传递吗?它是一致的吗?除此而外,编写单元测试加以排查,除非使用AutoValue框架(第49页)来生成equals方法,在这种情况下可以安全地省略测试。如果持有的属性失败,找出原因,并相应地修改equals方法。当然,equals方法也必须满足其他两个属性(自反性和非空性),但这两个属性通常都会满足。

在下面这个简单的PhoneNumber类中展示了根据之前的配方构建的equals方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public final class PhoneNumber {

private final short areaCode, prefix, lineNum;

public PhoneNumber(int areaCode, int prefix, int lineNum) {
this.areaCode = rangeCheck(areaCode, 999, "area code");
this.prefix = rangeCheck(prefix, 999, "prefix");
this.lineNum = rangeCheck(lineNum, 9999, "line num");
}

private static short rangeCheck(int val, int max, String arg) {
if (val < 0 || val > max)
throw new IllegalArgumentException(arg + ": " + val);

return (short) val;
}

@Override
public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof PhoneNumber))
return false;

PhoneNumber pn = (PhoneNumber) o;

return pn.lineNum == lineNum && pn.prefix == prefix
&& pn.areaCode == areaCode;
}

... // Remainder omitted
}

以下是一些最后提醒:

  1. 当重写equals方法时,同时也要重写hashCode方法(条目 11)。

  2. 不要让equals方法试图太聪明。如果只是简单地测试用于相等的属性,那么要遵守equals约定并不困难。如果你在寻找相等方面过于激进,那么很容易陷入麻烦。一般来说,考虑到任何形式的别名通常是一个坏主意。例如,File类不应该试图将引用的符号链接等同于同一文件对象。幸好 File 类并没这么做。

  3. 在equal 时方法声明中,不要将参数Object替换成其他类型。对于程序员来说,编写一个看起来像这样的equals方法并不少见,然后花上几个小时苦苦思索为什么它不能正常工作:

    1
    2
    3
    // Broken - parameter type must be Object!public boolean equals(MyClass o) {   

    }

    问题在于这个方法并没有重写Object.equals方法,它的参数是Object类型的,这样写只是重载了 equals 方法(Item 52)。 即使除了正常的方法之外,提供这种“强类型”的equals方法也是不可接受的,因为它可能会导致子类中的Override注解产生误报,提供不安全的错觉。 在这里,使用Override注解会阻止你犯这个错误(条目 40)。这个equals方法不会编译,错误消息会告诉你到底错在哪里:

    1
    2
    3
    4
    // Still broken, but won’t compile
    @Override public boolean equals(MyClass o) {

    }

    编写和测试equals(和hashCode)方法很繁琐,生的代码也很普通。替代手动编写和测试这些方法的优雅的手段是,使用谷歌AutoValue开源框架,该框架自动为你生成这些方法,只需在类上添加一个注解即可。在大多数情况下,AutoValue框架生成的方法与你自己编写的方法本质上是相同的。

很多 IDE(例如 Eclipse,NetBeans,IntelliJ IDEA 等)也有生成equals和hashCode方法的功能,但是生成的源代码比使用AutoValue框架的代码更冗长、可读性更差,不会自动跟踪类中的更改,因此需要进行测试。这就是说,使用IDE工具生成equals(和hashCode)方法通常比手动编写它们更可取,因为IDE工具不会犯粗心大意的错误,而人类则会。

总之,除非必须:在很多情况下,不要重写equals方法,从Object继承的实现完全是你想要的。 如果你确实重写了equals 方法,那么一定要比较这个类的所有重要属性,并且以保护前面equals约定里五个规定的方式去比较。


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!



Effective Java 3rd(Effective Java 第3版中文翻译) (1-5)

本文是根据《Effective Java 3rd》英文版翻译的,仅供自己学习用!

条目1. 考虑使用静态工厂方法替代构造方法

一个类允许客户端获取其实例的传统方式是提供一个公共构造方法。 其实还有另一种技术应该成为每个程序员工具箱的一部分。 一个类可以提供一个公共静态工厂方法,它只是一个返回类实例的静态方法。 下面是一个Boolean简单的例子(boolean基本类型的包装类)。 此方法将boolean基本类型转换为Boolean对象引用:

1
2
3
public static Boolean valueOf(boolean b) {
return b ? Boolean.TRUE : Boolean.FALSE;
}

注意,静态工厂方法与设计模式中的工厂方法模式不同[Gamma95]。本条目中描述的静态工厂方法在设计模式中没有直接的等价。

类可以为其客户端提供静态工厂方法,而不是公共构造方法。提供静态工厂方法而不是公共构造方法有优点也有缺点。

静态工厂方法的一个优点是,不像构造方法,它们是有名字的。 如果构造方法的参数本身并不描述被返回的对象,则具有精心选择名称的静态工厂更易于使用,并且生成的客户端代码更易于阅读。 例如,返回一个可能为素数的BigInteger的构造方法BigInteger(int,int,Random)可以更好地表示为名为BigInteger.probablePrime的静态工厂方法。 (这个方法是在Java 1.4中添加的。)

一个类只能有一个给定签名的构造方法。 程序员知道通过提供两个构造方法来解决这个限制,这两个构造方法的参数列表只有它们的参数类型的顺序不同。 这是一个非常糟糕的主意。 这样的API用户将永远不会记得哪个构造方法是哪个,最终会错误地调用。 阅读使用这些构造方法的代码的人只有在参考类文档的情况下才知道代码的作用。

因为他们有名字,所以静态工厂方法不会受到上面讨论中的限制。在类中似乎需要具有相同签名的多个构造方法的情况下,用静态工厂方法替换构造方法,并仔细选择名称来突出它们的差异。

静态工厂方法的第二个优点是,与构造方法不同,它们不需要每次调用时都创建一个新对象。这允许不可变的类(条目17)使用预先构建的实例,或者在构造时缓存实例,并反复分配它们以避免创建不必要的重复对象。boolean.valueof(boolean)方法说明了这种方法:它从不创建对象。这种技术类似于Flyweight模式[Gamma95]。如果经常请求等价对象,那么它可以极大地提高性能,特别是如果在创建它们非常昂贵的情况下。

静态工厂方法从重复调用返回相同对象的能力允许类保持在任何时候存在的实例的严格控制。这样做的类被称为实例控制( instance-controlled)。编写实例控制类的原因有很多。实例控制允许一个类来保证它是一个单例(3)项或不可实例化的(条目4)。同时,它允许一个不可变的值类(条目17)保证不存在两个相同的实例:当且仅当a== ba.equals(b)。这是享元模式的基础[Gamma95]。Enum类型(条目34)提供了这个保证。

静态工厂方法的第三个优点是,与构造方法不同,它们可以返回其返回类型的任何子类型的对象。 这为你在选择返回对象的类时提供了很大的灵活性。

这种灵活性的一个应用是API可以返回对象而不需要公开它的类。 以这种方式隐藏实现类会使 API非常紧凑I。 这种技术适用于基于接口的框架(条目20),其中接口为静态工厂方法提供自然返回类型。

在Java 8之前,接口不能有静态方法。根据约定,一个名为Type的接口的静态工厂方法被放入一个非实例化的伙伴类(companion class)(条目4)Types类中。例如,Java集合框架有45个接口的实用工具实现,提供不可修改的集合、同步集合等等。几乎所有这些实现都是通过静态工厂方法在一个非实例类(java .util. collections)中导出的。返回对象的类都是非公开的。

Collections框架API的规模要比它之前输出的45个单独的公共类要小得多,每个类有个便利类的实现。不仅是API的大部分减少了,还包括概念上的权重:程序员必须掌握的概念的数量和难度,才能使用API。程序员知道返回的对象恰好有其接口指定的API,因此不需要为实现类读阅读额外的类文档。此外,使用这种静态工厂方法需要客户端通过接口而不是实现类来引用返回的对象,这通常是良好的实践(条目64)。

从Java 8开始,接口不能包含静态方法的限制被取消了,所以通常没有理由为接口提供一个不可实例化的伴随类。 很多公开的静态成员应该放在这个接口本身。 但是,请注意,将这些静态方法的大部分实现代码放在单独的包私有类中仍然是必要的。 这是因为Java 8要求所有接口的静态成员都是公共的。 Java 9允许私有静态方法,但静态字段和静态成员类仍然需要公开。

静态工厂的第四个优点是返回对象的类可以根据输入参数的不同而不同。 声明的返回类型的任何子类都是允许的。 返回对象的类也可以随每次发布而不同。

EnumSet类(条目 36)没有公共构造方法,只有静态工厂。 在OpenJDK实现中,它们根据底层枚举类型的大小返回两个子类中的一个的实例:如果大多数枚举类型具有64个或更少的元素,静态工厂将返回一个RegularEnumSet实例, 返回一个long类型;如果枚举类型具有六十五个或更多元素,则工厂将返回一个JumboEnumSet实例,返回一个long类型的数组。

这两个实现类的存在对于客户是不可见的。 如果RegularEnumSet不再为小枚举类型提供性能优势,则可以在未来版本中将其淘汰,而不会产生任何不良影响。 同样,未来的版本可能会添加EnumSet的第三个或第四个实现,如果它证明有利于性能。 客户既不知道也不关心他们从工厂返回的对象的类别; 他们只关心它是EnumSet的一些子类。

静态工厂的第5个优点是,在编写包含该方法的类时,返回的对象的类不需要存在。这种灵活的静态工厂方法构成了服务提供者框架的基础,比如Java数据库连接API(JDBC)。服务提供者框架是提供者实现服务的系统,并且系统使得实现对客户端可用,从而将客户端从实现中分离出来。

服务提供者框架中有三个基本组:服务接口,它表示实现;提供者注册API,提供者用来注册实现;以及服务访问API,客户端使用该API获取服务的实例。服务访问API允许客户指定选择实现的标准。在缺少这样的标准的情况下,API返回一个默认实现的实例,或者允许客户通过所有可用的实现进行遍历。服务访问API是灵活的静态工厂,它构成了服务提供者框架的基础。

服务提供者框架的一个可选的第四个组件是一个服务提供者接口,它描述了一个生成服务接口实例的工厂对象。在没有服务提供者接口的情况下,必须对实现进行反射实例化(条目65)。在JDBC的情况下,Connection扮演服务接口的一部分,DriverManager.registerDriver提供程序注册API、DriverManager.getConnection是服务访问API,Driver是服务提供者接口。

服务提供者框架模式有许多变种。 例如,服务访问API可以向客户端返回比提供者提供的更丰富的服务接口。 这是桥接模式[Gamma95]。 依赖注入框架(条目5)可以被看作是强大的服务提供者。 从Java 6开始,平台包含一个通用的服务提供者框架java.util.ServiceLoader,所以你不需要,一般也不应该自己编写(条目59)。 JDBC不使用ServiceLoader,因为前者早于后者。

只提供静态工厂方法的主要限制是,没有公共或受保护构造方法的类不能被子类化。例如,在Collections框架中不可能将任何方便实现类子类化。可以说,这可能是因祸得福,因为它鼓励程序员使用组合而不是继承(条目18),并且是不可变类型(条目17)。

静态工厂方法的第二个缺点是,程序员很难找到它们。它们不像构造方法那样在API文档中突出,因此很难找出如何实例化一个提供静态工厂方法而不是构造方法的类。Javadoc工具可能有一天会引起对静态工厂方法的注意。与此同时,可以通过将注意力吸引到类或接口文档中的静态工厂以及遵守通用的命名约定来减少这个问题。下面是一些静态工厂方法的常用名称。以下清单并非完整:

  • from——A类型转换方法,它接受单个参数并返回此类型的相应实例,例如:Date d = Date.from(instant);
  • of——一个聚合方法,接受多个参数并返回该类型的实例,并把他们合并在一起,例如:Set<Rank> faceCards = EnumSet.of(JACK, QUEEN, KING);
  • valueOf——from和to更为详细的替代 方式,例如:BigInteger prime = BigInteger.valueOf(Integer.MAX_VALUE);
  • instance或getinstance——返回一个由其参数(如果有的话)描述的实例,但不能说它具有相同的值,例如:StackWalker luke = StackWalker.getInstance(options);
  • create 或 newInstance——与instance 或 getInstance类似,除了该方法保证每个调用返回一个新的实例,例如:Object newArray = Array.newInstance(classObject, arrayLen);
  • getType——与getInstance类似,但是如果在工厂方法中不同的类中使用。Type是工厂方法返回的对象类型,例如:FileStore fs = Files.getFileStore(path);
  • newType——与newInstance类似,但是如果在工厂方法中不同的类中使用。Type是工厂方法返回的对象类型,例如:BufferedReader br = Files.newBufferedReader(path);
  • type—— getType 和 newType简洁的替代方式,例如:List<Complaint> litany = Collections.list(legacyLitany);

总之,静态工厂方法和公共构造方法都有它们的用途,并且了解它们的相对优点是值得的。通常,静态工厂更可取,因此避免在没有考虑静态工厂的情况下提供公共构造方法。

条目2:当构造方法参数过多时使用builder模式

静态工厂和构造方法都有一个限制:它们不能很好地扩展到很多可选参数的情景。请考虑一个代表包装食品上的营养成分标签的例子。这些标签有几个必需的属性——每次建议的摄入量,每罐的份量和每份卡路里 ,以及超过20个可选的属性——总脂肪、饱和脂肪、反式脂肪、胆固醇、钠等等。大多数产品都有非零值,只有少数几个可选属性。

应该为这样的类编写什么样的构造方法或静态工厂?传统上,程序员使用了可伸缩(telescoping constructor)构造方法模式,在这种模式中,只提供了一个只所需参数的构造函数,另一个只有一个可选参数,第三个有两个可选参数,等等,最终在构造函数中包含所有可选参数。这就是它在实践中的样子。为了简便起见,只显示了四个可选属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// Telescoping constructor pattern - does not scale well!

public class NutritionFacts {
private final int servingSize; // (mL) required
private final int servings; // (per container) required
private final int calories; // (per serving) optional
private final int fat; // (g/serving) optional
private final int sodium; // (mg/serving) optional
private final int carbohydrate; // (g/serving) optional

public NutritionFacts(int servingSize, int servings) {
this(servingSize, servings, 0);
}

public NutritionFacts(int servingSize, int servings,
int calories) {
this(servingSize, servings, calories, 0);
}

public NutritionFacts(int servingSize, int servings,
int calories, int fat) {
this(servingSize, servings, calories, fat, 0);
}

public NutritionFacts(int servingSize, int servings,
int calories, int fat, int sodium) {
this(servingSize, servings, calories, fat, sodium, 0);
}

public NutritionFacts(int servingSize, int servings,
int calories, int fat, int sodium, int carbohydrate) {
this.servingSize = servingSize;
this.servings = servings;
this.calories = calories;
this.fat = fat;
this.sodium = sodium;
this.carbohydrate = carbohydrate;
}
}

当想要创建一个实例时,可以使用包含所有要设置的参数的最短参数列表的构造方法:

1
NutritionFacts cocaCola = new NutritionFacts(240, 8, 100, 0, 35, 27);

通常情况下,这个构造方法的调用需要许多你不想设置的参数,但是你不得不为它们传递一个值。 在这种情况下,我们为fat属性传递了0值。 『只有』六个参数可能看起来并不那么糟糕,但随着参数数量的增加,它会很快失控。

简而言之,可伸缩构造方法模式是有效的,但是当有很多参数时,很难编写客户端代码,而且很难读懂它。读者不知道这些值是什么意思,并且必须仔细地计算参数才能找到答案。一长串相同类型的参数可能会导致一些细微的bug。如果客户端意外地反转了两个这样的参数,编译器并不会抱怨,但是程序在运行时会出现错误行为(条目51)。

当在构造方法中遇到许多可选参数时,另一种选择是JavaBeans模式,在这种模式中,调用一个无参数的构造函数来创建对象,然后调用setter方法来设置每个必需的参数和可选参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// JavaBeans Pattern - allows inconsistency, mandates mutability

public class NutritionFacts {
// Parameters initialized to default values (if any)
private int servingSize = -1; // Required; no default value
private int servings = -1; // Required; no default value
private int calories = 0;
private int fat = 0;
private int sodium = 0;
private int carbohydrate = 0;

public NutritionFacts() { }

// Setters
public void setServingSize(int val) { servingSize = val; }
public void setServings(int val) { servings = val; }
public void setCalories(int val) { calories = val; }
public void setFat(int val) { fat = val; }
public void setSodium(int val) { sodium = val; }
public void setCarbohydrate(int val) { carbohydrate = val; }
}

这种模式没有伸缩构造方法模式的缺点。有点冗长,但创建实例很容易,并且易于阅读所生成的代码:

1
2
3
4
5
6
NutritionFacts cocaCola = new NutritionFacts();
cocaCola.setServingSize(240);
cocaCola.setServings(8);
cocaCola.setCalories(100);
cocaCola.setSodium(35);
cocaCola.setCarbohydrate(27);

不幸的是,JavaBeans模式本身有严重的缺陷。由于构造方法在多次调用中被分割,所以在构造过程中JavaBean可能处于不一致的状态。该类没有通过检查构造参数参数的有效性来执行一致性的选项。在不一致的状态下尝试使用对象可能会导致与包含bug的代码大相径庭的错误,因此很难调试。一个相关的缺点是,JavaBeans模式排除了让类不可变的可能性(条目17),并且需要在程序员的部分增加工作以确保线程安全。

当它的构造完成时,手动“冻结”对象,并且不允许它在解冻之前使用,可以减少这些缺点,但是这种变体在实践中很难使用并且很少使用。 而且,在运行时会导致错误,因为编译器无法确保程序员在使用对象之前调用freeze方法。

幸运的是,还有第三种选择,它结合了可伸缩构造方法模式的安全性和javabean模式的可读性。 它是Builder模式[Gamma95]的一种形式。客户端不直接调用所需的对象,而是调用构造方法(或静态工厂),并使用所有必需的参数,并获得一个builder对象。然后,客户端调用builder对象的setter相似方法来设置每个可选参数。最后,客户端调用一个无参的build方法来生成对象,该对象通常是不可变的。Builder通常是它所构建的类的一个静态成员类(条目24)。以下是它在实践中的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// Builder Pattern

public class NutritionFacts {
private final int servingSize;
private final int servings;
private final int calories;
private final int fat;
private final int sodium;
private final int carbohydrate;

public static class Builder {
// Required parameters
private final int servingSize;
private final int servings;

// Optional parameters - initialized to default values
private int calories = 0;
private int fat = 0;
private int sodium = 0;
private int carbohydrate = 0;

public Builder(int servingSize, int servings) {
this.servingSize = servingSize;
this.servings = servings;
}

public Builder calories(int val) {
calories = val;
return this;
}

public Builder fat(int val) {
fat = val;
return this;
}

public Builder sodium(int val) {
sodium = val;
return this;
}

public Builder carbohydrate(int val) {
carbohydrate = val;
return this;
}

public NutritionFacts build() {
return new NutritionFacts(this);
}
}

private NutritionFacts(Builder builder) {
servingSize = builder.servingSize;
servings = builder.servings;
calories = builder.calories;
fat = builder.fat;
sodium = builder.sodium;
carbohydrate = builder.carbohydrate;
}
}

NutritionFacts类是不可变的,所有的参数默认值都在一个地方。builder的setter方法返回builder本身,这样调用就可以被链接起来,从而生成一个流畅的API。下面是客户端代码的示例:

1
2
NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8)
.calories(100).sodium(35).carbohydrate(27).build();

这个客户端代码很容易编写,更重要的是易于阅读。 Builder模式模拟Python和Scala中的命名可选参数。

为了简洁起见,省略了有效性检查。 要尽快检测无效参数,检查builder的构造方法和方法中的参数有效性。 在build方法调用的构造方法中检查包含多个参数的不变性。为了确保这些不变性不受攻击,在从builder复制参数后对对象属性进行检查(条目 50)。 如果检查失败,则抛出IllegalArgumentException异常(条目 72),其详细消息指示哪些参数无效(条目 75)。

Builder模式非常适合类层次结构。 使用平行层次的builder,每个嵌套在相应的类中。 抽象类有抽象的builder; 具体的类有具体的builder。 例如,考虑代表各种比萨饼的根层次结构的抽象类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Builder pattern for class hierarchies

import java.util.EnumSet;
import java.util.Objects;
import java.util.Set;

public abstract class Pizza {
public enum Topping {HAM, MUSHROOM, ONION, PEPPER, SAUSAGE}
final Set<Topping> toppings;

abstract static class Builder<T extends Builder<T>> {
EnumSet<Topping> toppings = EnumSet.noneOf(Topping.class);

public T addTopping(Topping topping) {
toppings.add(Objects.requireNonNull(topping));
return self();
}

abstract Pizza build();

// Subclasses must override this method to return "this"
protected abstract T self();
}

Pizza(Builder<?> builder) {
toppings = builder.toppings.clone(); // See Item 50
}
}

请注意,Pizza.Builder是一个带有递归类型参数( recursive type parameter)(条目 30)的泛型类型。 这与抽象的self方法一起,允许方法链在子类中正常工作,而不需要强制转换。 Java缺乏自我类型的这种变通解决方法被称为模拟自我类型(simulated self-type)的习惯用法。

这里有两个具体的Pizza的子类,其中一个代表标准的纽约风格的披萨,另一个是半圆形烤乳酪馅饼。前者有一个所需的尺寸参数,而后者则允许指定酱汁是否应该在里面或在外面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import java.util.Objects;

public class NyPizza extends Pizza {
public enum Size { SMALL, MEDIUM, LARGE }
private final Size size;

public static class Builder extends Pizza.Builder<Builder> {
private final Size size;

public Builder(Size size) {
this.size = Objects.requireNonNull(size);
}

@Override public NyPizza build() {
return new NyPizza(this);
}

@Override protected Builder self() {
return this;
}
}

private NyPizza(Builder builder) {
super(builder);
size = builder.size;
}
}

public class Calzone extends Pizza {
private final boolean sauceInside;

public static class Builder extends Pizza.Builder<Builder> {
private boolean sauceInside = false; // Default

public Builder sauceInside() {
sauceInside = true;
return this;
}

@Override public Calzone build() {
return new Calzone(this);
}

@Override protected Builder self() {
return this;
}
}

private Calzone(Builder builder) {
super(builder);
sauceInside = builder.sauceInside;
}
}

请注意,每个子类builder中的build方法被声明为返回正确的子类:NyPizza.Builderbuild方法返回NyPizza,而Calzone.Builder中的build方法返回Calzone。 这种技术,其一个子类的方法被声明为返回在超类中声明的返回类型的子类型,称为协变返回类型( covariant return typing)。 它允许客户端使用这些builder,而不需要强制转换。

这些“分层builder”的客户端代码基本上与简单的NutritionFacts builder的代码相同。为了简洁起见,下面显示的示例客户端代码假设枚举常量的静态导入:

1
2
3
4
NyPizza pizza = new NyPizza.Builder(SMALL)
.addTopping(SAUSAGE).addTopping(ONION).build();
Calzone calzone = new Calzone.Builder()
.addTopping(HAM).sauceInside().build();

builder对构造方法的一个微小的优势是,builder可以有多个可变参数,因为每个参数都是在它自己的方法中指定的。或者,builder可以将传递给多个调用的参数聚合到单个属性中,如前面的addTopping方法所演示的那样。

Builder模式非常灵活。 单个builder可以重复使用来构建多个对象。 builder的参数可以在构建方法的调用之间进行调整,以改变创建的对象。 builder可以在创建对象时自动填充一些属性,例如每次创建对象时增加的序列号。

Builder模式也有缺点。为了创建对象,首先必须创建它的builder。虽然创建这个builder的成本在实践中不太可能被注意到,但在性能关键的情况下可能会出现问题。而且,builder模式比伸缩构造方法模式更冗长,因此只有在有足够的参数时才值得使用它,比如四个或更多。但是请记住,如果希望在将来添加更多的参数。但是,如果从构造方法或静态工厂开始,并切换到builder,当类演化到参数数量失控的时候,过时的构造方法或静态工厂就会面临尴尬的处境。因此,所以,最好从一开始就创建一个builder。

总而言之,当设计类的构造方法或静态工厂的参数超过几个时,Builder模式是一个不错的选择,特别是如果许多参数是可选的或相同类型的。客户端代码比使用伸缩构造方法(telescoping constructors)更容易读写,并且builder比JavaBeans更安全。

3. 使用私有构造方法或枚类实现Singleton属性

单例是一个仅实例化一次的类[Gamma95]。单例对象通常表示无状态对象,如函数(条目 24)或一个本质上唯一的系统组件。让一个类成为单例会使测试它的客户变得困难,因为除非实现一个作为它类型的接口,否则不可能用一个模拟实现替代单例。

有两种常见的方法来实现单例。两者都基于保持构造方法私有和导出公共静态成员以提供对唯一实例的访问。在第一种方法中,成员是final修饰的属性:

1
2
3
4
5
6
// Singleton with public final field
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() { ... }
public void leaveTheBuilding() { ... }
}

私有构造方法只调用一次,来初始化公共静态 final Elvis.INSTANCE属性。缺少一个公共的或受保护的构造方法,保证了全局的唯一性:一旦Elvis类被初始化,一个Elvis的实例就会存在——不多也不少。客户端所做的任何事情都不能改变这一点,但需要注意的是:特权客户端可以使用AccessibleObject.setAccessible方法,以反射方式调用私有构造方法(条目 65)。如果需要防御此攻击,请修改构造函数,使其在请求创建第二个实例时抛出异常。

在第二个实现单例的方法中,公共成员是一个静态的工厂方法:

1
2
3
4
5
6
7
8
// Singleton with static factory
public class Elvis {
private static final Elvis INSTANCE = new Elvis();
private Elvis() { ... }
public static Elvis getInstance() { return INSTANCE; }

public void leaveTheBuilding() { ... }
}

所有对Elvis.getInstance的调用都返回相同的对象引用,并且不会创建其他的Elvis实例(与前面提到的警告相同)。

公共属性方法的主要优点是API明确表示该类是一个单例:公共静态属性是final的,所以它总是包含相同的对象引用。 第二个好处是它更简单。

静态工厂方法的一个优点是,它可以灵活地改变你的想法,无论该类是否为单例而不必更改其API。 工厂方法返回唯一的实例,但是可以修改,比如,返回调用它的每个线程的单独实例。 第二个好处是,如果你的应用程序需要它,可以编写一个泛型单例工厂(generic singleton factory )(条目30)。 使用静态工厂的最后一个优点是方法引用可以用supplier,例如Elvis :: instance等同于Supplier<Elvis>。 除非与这些优点相关的,否则公共属性方法是可取的。

创建一个使用这两种方法的单例类(第12章),仅仅将implements Serializable添加到声明中是不够的。为了维护单例的保证,声明所有的实例属性为transient,并提供一个readResolve方法(条目89)。否则,每当序列化实例被反序列化时,就会创建一个新的实例,在我们的例子中,导致出现新的Elvis实例。为了防止这种情况发生,将这个readResolve方法添加到Elvis类:

1
2
3
4
5
6
// readResolve method to preserve singleton property
private Object readResolve() {
// Return the one true Elvis and let the garbage collector
// take care of the Elvis impersonator.
return INSTANCE;
}

实现一个单例的第三种方法是声明单一元素的枚举类:

1
2
3
4
5
6
// Enum singleton - the preferred approach
public enum Elvis {
INSTANCE;

public void leaveTheBuilding() { ... }
}

这种方式类似于公共属性方法,但更简洁,提供了免费的序列化机制,并提供了针对多个实例化的坚固保证,即使是在复杂的序列化或反射攻击的情况下。这种方法可能感觉有点不自然,但是单一元素枚举类通常是实现单例的最佳方式。注意,如果单例必须继承Enum以外的父类(尽管可以声明一个Enum来实现接口),那么就不能使用这种方法。

4. 使用私有构造方法执行非实例化

偶尔你会想写一个类,它只是一组静态方法和静态属性。 这样的类获得了不好的名声,因为有些人滥用这些类而避免以面向对象方式思考,但是它们确实有着特殊的用途。 它们可以用来按照java.lang.Mathjava.util.Arrays的方式,在基本类型的数值或数组上组织相关的方法。 它们也可以用于将静态方法(包括工厂(条目 1))分组,用于实现某个接口的对象,其方式为java.util.Collections。 (从Java 8开始,你也可以将这些方法放在接口中,假如它是你自己修改的。)最后,这样的类可以用于在final类上对方法进行分组,因为不能将它们放在子类中。

这样的实用类( utility classes)不是设计用来被实例化的:一个实例是没有意义的。然而,在没有显式构造方法的情况下,编译器提供了一个公共的、无参的默认构造方法。对于用户来说,该构造方法与其他构造方法没有什么区别。在已发布的 API中经常看到无意识的被实例的类。

试图通过创建抽象类来强制执行非实例化是行不通的。该类可以被子类化,子类可以被实例化。此外,它误导用户认为该类是为继承而设计的(条目 19)。不过,有一个简单的方法来确保非实例化。只有当类不包含显式构造方法时,才会生成一个默认构造方法,因此可以通过包含一个私有构造方法来实现类的非实例化:

1
2
3
4
5
6
7
8
// Noninstantiable utility class
public class UtilityClass {
// Suppress default constructor for noninstantiability
private UtilityClass() {
throw new AssertionError();
}
... // Remainder omitted
}

因为显式构造方法是私有的,所以在类之外是不可访问的。AssertionError异常不是严格要求的,但是它提供了一种保证,以防在类中意外地调用构造方法。它保证类在任何情况下都不会被实例化。这个习惯用法有点违反直觉,好像构造方法就是设计成不能调用的一样。因此,如前面所示,添加注释是种明智的做法。

这种习惯有一个副作用,阻止了类的子类化。所有的构造方法都必须显式或隐式地调用父类构造方法,而子类则没有可访问的父类构造方法来调用。

5. 使用依赖注入取代硬连接资源(hardwiring resources)

许多类依赖于一个或多个底层资源。例如,拼写检查器依赖于字典。将此类类实现为静态实用工具类并不少见(条目 4):

1
2
3
4
5
6
7
8
9
// Inappropriate use of static utility - inflexible & untestable!
public class SpellChecker {
private static final Lexicon dictionary = ...;

private SpellChecker() {} // Noninstantiable

public static boolean isValid(String word) { ... }
public static List<String> suggestions(String typo) { ... }
}

同样地,将它们实现为单例也并不少见(条目 3):

1
2
3
4
5
6
7
8
9
10
// Inappropriate use of singleton - inflexible & untestable!
public class SpellChecker {
private final Lexicon dictionary = ...;

private SpellChecker(...) {}
public static INSTANCE = new SpellChecker(...);

public boolean isValid(String word) { ... }
public List<String> suggestions(String typo) { ... }
}

这两种方法都不令人满意,因为他们假设只有一本字典值得使用。在实际中,每种语言都有自己的字典,特殊的字典被用于特殊的词汇表。另外,使用专门的字典来进行测试也是可取的。想当然地认为一本字典就足够了,这是一厢情愿的想法。

可以通过使dictionary属性设置为非final,并添加一个方法来更改现有拼写检查器中的字典,从而让拼写检查器支持多个字典,但是在并发环境中,这是笨拙的、容易出错的和不可行的。静态实用类和单例对于那些行为被底层资源参数化的类来说是不合适的

所需要的是能够支持类的多个实例(在我们的示例中,即SpellChecker),每个实例都使用客户端所期望的资源(在我们的例子中是dictionary)。满足这一需求的简单模式是在创建新实例时将资源传递到构造方法中。这是依赖项注入(dependency injection)的一种形式:字典是拼写检查器的一个依赖项,当它创建时被注入到拼写检查器中。

1
2
3
4
5
6
7
8
9
10
11
// Dependency injection provides flexibility and testability
public class SpellChecker {
private final Lexicon dictionary;

public SpellChecker(Lexicon dictionary) {
this.dictionary = Objects.requireNonNull(dictionary);
}

public boolean isValid(String word) { ... }
public List<String> suggestions(String typo) { ... }
}

依赖注入模式非常简单,许多程序员使用它多年而不知道它有一个名字。 虽然我们的拼写检查器的例子只有一个资源(字典),但是依赖项注入可以使用任意数量的资源和任意依赖图。 它保持了不变性(条目 17),因此多个客户端可以共享依赖对象(假设客户需要相同的底层资源)。 依赖注入同样适用于构造方法,静态工厂(条目 1)和 builder模式(条目 2)。

该模式的一个有用的变体是将资源工厂传递给构造方法。 工厂是可以重复调用以创建类型实例的对象。 这种工厂体现了工厂方法模式(Factory Method pattern )[Gamma95]。 Java 8中引入的Supplier <T>接口非常适合代表工厂。 在输入上采用Supplier<T>的方法通常应该使用有界的通配符类型( bounded wildcard type)(条目 31)约束工厂的类型参数,以允许客户端传入工厂,创建指定类型的任何子类型。 例如,下面是一个使用客户端提供的工厂生成tile的方法: Mosaic create(Supplier<? extends Tile> tileFactory) { ... }

尽管依赖注入极大地提高了灵活性和可测试性,但它可能使大型项目变得混乱,这些项目通常包含数千个依赖项。使用依赖注入框架(如Dagger[Dagger]、Guice[Guice]或Spring[Spring])可以消除这些混乱。这些框架的使用超出了本书的范围,但是请注意,为手动依赖注入而设计的API非常适合这些框架的使用。

总之,不要使用单例或静态的实用类来实现一个类,该类依赖于一个或多个底层资源,这些资源的行为会影响类的行为,并且不让类直接创建这些资源。相反,将资源或工厂传递给构造方法(或静态工厂或builder模式)。这种称为依赖注入的实践将极大地增强类的灵活性、可重用性和可测试性。





TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!



分析、分析,首先是要学会“分”

周四的时候欧阳(同事)给我发了一个截图,说已经看到好几个MBA教授的课里面讲到了四渡赤水,讲到了指挥的艺术。关于四渡赤水,确实是神来之笔,值得大书特书,里面的指挥艺术是非常复杂的,说实话我现在也没看懂….但是里面有一个非常好的分析方法值得我们学习,如果我们掌握,应该可以有效帮助我们在生活工作中,打开一个新的视野,解决一些棘手的难题。

下面我会将讲两个例子来说明这个分析的方法,可以先说一下这个方法:分析,要先会“分”

中国社会各阶级的分析

我们把四渡赤水放在后面讲,第一个例子其实是《毛选》的第一篇文章,《中国社会各阶级的分析》。一九二五年底的时候党内以陈独秀为代表右倾投降派呢,感觉我党孤立无援,甚至都想和国民党合作了。

针对这个问题,毛主席专门写了这篇文章,目的是从中国社会的各阶层中分出谁是我们的朋友?谁是我们的敌人?也就是我们应该团结谁,应该反对谁?

只有这样去分析,才能非常接地气地了解中国革命的现状。

谁是我们的朋友,谁是我们的敌人

最后得出:当时的我党应该转战农村,联合广大农民阶级,才是中国革命成功的关键。

各位,你想想,我们身处的只是很小的一个行业,都很难看清楚当下格局。而毛主席用“分”的思想,就把中国革命这个大行业分析地彻彻底底,“分”这种思考问题的方法有多么可怕。


从“分”的角度来看四渡赤水

第二个例子就是四渡赤水的时候,面对国民党150个团的包围、追击和拦截,如果我们按下象棋、下军棋的角度来看的话,那红军当时应该根本就不可能有活路。但是毛主席和红军指战员开挂一般用了高超的指挥艺术,虚虚实实,把敌人四处调度,解开了这个几乎无解的难题。

四渡赤水

在这里,我们深入来看,发挥出高超指挥艺术的前提是对现状和问题的分析,毛主席对于当时面临的问题分析是极其透彻的。我们可以看到毛主席应用“分”的思想来分析敌人,整个态势一下子就出现了很多生机。分,就把当时国民党的各部队的情况分开来看,因为他们各自的想法是不一样的:

  • 中央军薛岳兵团:那肯定是毫不保留地追杀红军;

  • 贵州、云南和湖南的部队:也在拼命截杀,但战斗力相对较弱;

  • 广东陈济棠:双方可以互通情报、互相通商、互相借道等五项协议;

  • 广西白崇禧:不拦头、不斩腰,只击尾,赶出属地就好,不真干;

  • 四川邓锡侯、杨森:互不侵犯。

对敌人这么一“分”,战局上的态势是不是一样子就有转机了,对每个敌人有不同分析,后面才有四渡赤水这样的神来之笔。

从以上两个例子,我们看到到“分”的思路,大家可以琢磨琢磨,这个思想用好了,真的非常可怕,很多问题应该就会出现一些生机。

给我们的启示

大家应该都不太可能遇到上面两个例子中的生死存亡的问题,那么,“分”的思想对我工作和生活有什么启示呢?

我们觉得最大的启示不在“分”这个动作本身,而在于对数据的收集、分析和应用。毕竟没有数据,你知道了分的思想也无法具体执行。所以我们目前在产品涉及开发、项目运营等方面,都会在数据方面投入重兵,如数据规划和采集、数据埋点设计、结合业务的数据分析,以及基于数据驱动的业务改进。有了一套完善的数据治理和使用体系,才能去分析问题,解决问题。

关于数据体系,后面再专门来讲吧。


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!



聊聊我理解的数字化

第一次对“数字化”三个字有深刻的印象,还是几年前听一个在政府工作的朋友说的一个小段子开始的:有一次他们给一位大领导讲“数智化”,这位大领导听了之后,就和他们说,你们还是想想怎么把“数字化”研究透、做扎实,不要好高骛远。不久之后,数字化改革就从浙江开始蔓延,变成了一条不容忽视的赛道。今天不想讨论数智化和数字化的区别,只想谈谈我自己理解的数字化,以及数字化会为我们这些从业者和社会大众带来什么。

数字化改革进程是一段下坡路

首先我不想去解释数字化的定义,因为定义有很多,而且多数定义对非专业人员来说并不友好,比如“现实世界在数字上的投影”,刻意去理解这些定义没有意义,大家还不如继续看下去,然后得出自己对数字化的理解。

数字化不算是新鲜事,而且甚至都不是一种划时代的技术创新,我对数字化进程的理解是,这是一段下坡路。嗯,先别急着喷我,下坡路并不一定是贬义的。

如果以历史观的角度去看问题,计算机、互联网、移动互联网,还有物联网等都可以认为是一种原生创新,是有实质性地技术和产品的,而且往往伴随着巨大的投入和市场,我们可以把这些创新称之为走上坡路,这些创新衍生了很多新的行业与岗位。计算机、互联网什么的就不说了,就说移动互联网,那对于人类社会来说也是有巨大改变的,比如原来的信息世界是端到端的,是两台电脑之间的联系,你从电脑前面起身离开,你还是你,互联网还是互联网。但是移动互联网却把互联网和人紧紧拴在一起了,除了睡觉和坐飞机(头等舱除外),人和互联网是连通着的,或者人和人之间是随时相互连接着的。移动互联网也催生了很多新的应用,在我们身边的就有微信、外卖服务、网约车等等,那些2008年之后不断冒出来的新应用场景其实都是移动互联网造就的红利。

但是我们发现近几年在世界范围内,这种技术创新带来的红利在消失(或者创新开始减速),人工智能感觉呼之欲出,但是它依然还达不到全社会广泛应用的层面。这个时候,却正是我们想办法把近二十年的技术创新(互联网、移动互联网、大数据技术、物联网和人工智能等)形成的势能消化掉的大好时机。就像滑雪一样,我们辛辛苦苦爬到山顶(现在都是坐着缆车上山了),就是为了又快又优雅地滑下来。

也就是说,数字化进程其实不算一个原生创新的过程,而是一个对之前的投入进行收获的阶段。所以,如何在数字化进程中产生更多和业务关联的应用场景,让我们的生产生活可以更加便捷高效,就成了数字化进程的核心使命,这一阶段是要产生明显的社会效益和经济效益的。

数字化案例

企业为了获得更丰厚的利润或保持市场竞争力,在数字化改革方面的投入其实早就开始了,或许他们并不把这个过程称之为数字化改革,但是实际效果是差不多的,且更聚焦于经济效益的提升。

从前台服务员的颜值开始讲数字化案例

之前和一位从事酒店数字化的朋友聊天,感触颇深。

不知道大家有没有感受到,现在大部分的连锁酒店前台服务员的平均颜值没有十年前那么高了。其中很大一个原因,就是目前连锁酒店的市场饱和度太高,现有利润无法维持服务员的颜值水平。管中窥豹,连锁酒店对于降低运营成本的诉求是非常强烈的。所以我们看到一些酒店做了一些非常有效的手段来降低运营成本:

  • 水箱球阀控制:根据酒店入住人数控制楼顶水箱球阀高度,客人少的时候球阀高度自动下降,减少泵水频次,省电节流;
  • 房间朝向分配:系统会在夏天优先分配朝北房间,冬天优先分配朝南房间,主要是为了减少客人使用空调的时间,省电节流;
  • 使用送货机器人:原先在前台至少需要全天候同时安排两名服务员,一人值守,一人可以处理送茶叶、送矿泉水、送餐等事务。现在送货的工作交给机器人,可以在非繁忙阶段减少一名服务员上岗,减少人员成本支出;
  • 数字化电路管网:在以前,当阿姨发现某个房间的灯坏了,打电话给维修部,维修部的师傅火急火燎赶过来,发现不是灯泡烧坏了,而是里面的线路坏了,然后又回去拿更大的家伙和元件过来替换,这个过程就浪费了各种时间。上了数字化电路管网,维修师傅也许会比阿姨更早发现哪个分店哪个房间的灯坏了,然后拿着对应工具和元件,跑一趟就搞定了。好处是,类似于一个西湖区,原来为了保持及时修复的速度,需要有5个维修师傅,但是现在可能只需要两个,减少了人员成本支出。

很多行业目前还到不了这么精细化运营的时候,成本压力没这么大,但这个时候迟早会来。这个案例恰恰体现了数字化带来的优势,让企业在竞争中处于更加高效的水平。也许你会觉得和一套数字化系统下去,价格远远高于人工成本,那我告诉你,中国现在的产业升级还是很迅猛的,各种五险一金越来越正规化,以及年轻人就业方向越来越多之后,以前那种粗放的人口红利也在逐步消失。

解决批量化与个性化的数字化案例

回到旅游场景。

游客服务依然还是一个棘手的问题,它不像营销服务,是可以很明确地量化收入的;也不像管理,可以抓大放小,游客服务很多时候是需要面对各种不同的个性化需求。我们过去在服务上的处理方式,更多是去看游客画像,做数据分析,比如看到27%的人是有行李寄存需求的,就会在公众号展示行李寄存服务入口,在游客服务中心放置KT板等。但是这样的服务方式依然只能照顾到一小部分人,那么如何才能服务更多的游客,批量地去照顾他们不同的需求呢?

从2021年开始,我们在游客服务数字化方面做了一些实践,可以大家分享一点心得。

在颐和园、普陀山等景区的游客服务数字化方面,我们发现汇总后的游客服务需求有801类,这个量级的需求,说的通俗一点就是“五花八门”,如果要靠人工去服务,绝对不可能面面俱到了。我们这两年在逐步的摸索中找到了一条比较好的路径,就是数据+业务知识+服务应用,具体的做法说起来也是很简单的,就是利用基于手机端的人工智能咨询服务接收游客提出的各种游前、游中的问题,通过业务知识系统(景区服务知识库+知识图谱业务推演)找到可以帮助游客解决问题的答案,然后实时反馈给游客,实现对游客个性化需求的及时满足。比如在同一时刻,不同游客在问的问题可能是“求学应该去哪里拜”、“没带身份证可以坐船吗?”、“可以在XXX餐厅帮我订两个位置吗?”,这些需求要解决,恨不得每个游客身边都跟一个服务专员,而且他们可能也无法面面俱到。但是对景区服务业务知识进行梳理和架构,再配合像包车、讲解员预约、送餐、订座、线上排队、电子导览等各类服务的线上化之后,就可以批量地同时为成百上千游客解决他们的需求。

除了实时反馈,后台也会通过游客需求洞察给景区提供服务优化建议,比如哪些可能是景区服务中的盲区,哪些商业业态是游客需求比较旺盛但是目前还没有的,以及哪些环节上的服务是很容易引起游客负面情绪的。

2023年我们还在做一次大的游客服务升级实践,会更智能、更有趣,后续玩转起来了,可以专门分享一次。

身边数字化的案例其实已经挺多的了,这里就不再一一列举。但是数字化的重点并不是在技术层面创新,而是合理应用现有技术,结合业务,将价值推送至用户可以触达的应用端,去做效益提升。

数字化的难点在哪里

数字化比研究一门技术要难,难就难在以下几点:

  • 跨领域、重领域知识:就技术说技术是无法适应数字化时代的,因为不是把C++、Java或者分布式搞的遛遛的就行,而是要把行业服务好,比如怎么样用技术把工厂的人均产值做的更高,这是需要深度结合业务知识,对行业领域知识非常精通才能做好创新;

  • 数据治理:数据和数据处理是数字化的基础。在技术层面目前已经有比较成熟的能力,存储方面有RMDB、Hadoop(Hdfs)、MongoDB、Cassandra和Neo4j等各类关系型/非关系型数据库,在处理方面有Clickhouse、Spark、Kafka等。但数据处理的难点却在源头,就是数据采集和汇总方面。政府、机构和企业,在数据汇总方面遇到的最大的难题不是技术,而是法律法规与合理的数据治理规划;

  • 规划与运营:至少在现阶段,数字化改革的方向应该是自上而下的,需要一把手发起,全员重视。在启动数改前,就应该有清晰的规划目的,政府和机构的目标可能是社会效益,企业则瞄准经济效益,制定大目标之后,需要拆解多个可以支撑这个大目标达成的可量化指标,并一层层拆解下去(可参看《聊聊我理解的业务架构》),到最后的运营动作和数字化工具系统。运营是另外一个非常关键的点,仅仅一堆系统是无法给我们带来太多的效益的,很多时候,只有匹配数字化改革去重构运营流程,才能获得真正的效益提升;

  • 重应用:以前我们做大数据,客户关心的最终成果往往是维度全面、界面元素丰富的分析大屏,但是各大屏给他们的业务带来多大帮助,我们自己也是很怀疑的。但在数字化改革场景下,产品和技术人员需要把基于数据驱动的应用做出来,让最终用户可以在工作中直接受益。比如基于今天18点的预订游客数,度假区的餐饮部门在19点会自动收到明天备菜的数量,并在经过厨师长/经理确认后直接发给各供应商,减少食材浪费;

  • 效果量化:区别于原先信息化的建设模式,前面已经讲了数字化改革需要在场景中结合运营赋能来达到最终效果。但是如何去评判效果,这就需要在各类应用中都标配用户使用效果的数据收集和反馈,制定效果评判和改进的SOP,完成应用的迭代优化。

数字化浪潮的启示

这波数字化浪潮给我最大的启示是:要坚持走上坡(创新)积聚势能,也要不时地滑滑坡,把势能转化成动能(优势)。

最后,如果你所在的企业刚好属于数字化服务的范畴,或者你自己有意加入数字化浪潮中来,那也许可以考虑以下三点建议:

一、不管你在什么岗位,都需要去掌握或了解一定的技术,对于技术不了解会影响你的后续发展;

二、一定要深耕行业,没有大量行业知识的积累,根本没办法为客户创造真正的价值,我指的是社会效益和经济效益;

三、不断交流和学习,不仅仅是书,还有行业案例,多与行业专家交流,多向客户学习,尽可能接触一线市场。


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!



聊聊我理解的业务架构

文旅行业的交钥匙工程

在文旅行业里面,有一个问题是从业者不太愿意说,但却无法回避:就是大部分的项目其实在交付之后并没有发挥出真正的价值。

这些项目往往有一个统一的名字——“交钥匙工程”,这可不是一个褒义词,而是意味着交付之后,这个项目的整个生命周期就完结了,因为多数情况下是无法运营起来的。造成无法运营的原因有很多,比如很大一部分业内人士认为是没有合适的运营团队和运营投入造成的。而我觉得这个锅不能运营来背,根本原因是在项目开始阶段,就缺少合理的业务架构设计,以至于在交付之后,运营人员的运营路径和系统是不匹配的。

那么什么是业务架构设计呢?

作为一个十多年的码农,我本来更适合讲的是系统架构,而不是业务架构,但是最近三年的从业经历中的一些亲身体验以及见过的一些“灾难级”项目,觉得还是要和大家分享一下我自己理解的业务架构设计,能给哪怕一两位从业者带来一些新的思考也是好的。

我认为的业务架构设计包含了规划设计、最终价值目标确定、价值目标的分解,以及实现目标所需要的抓手和配套的运营动作和工具。大部分的项目在开始的时候客户肯定就会有一个最初的目的,如何去理解这个目的,然后一直分解推演到运营工作计划和系统功能清单,就是业务架构设计的工作。设计过程中是需要和最终的运营团队有深入交流的,如果在设计阶段还没有确定运营团队,那么对架构师的能力要求则会更高,就需要他们本身有较强的运营能力。业务架构设计的越好,后续的系统研发会非常有明确指向,运营团队接管后也会有非常匹配的各种抓手助力他们运营,才能真正把项目持续的运营起来。

一个真实的案例

以上说了这么多生硬的“道理”,接下来我们先来看一个真实的例子,为了不泄露太多信息,会保留一些重要信息,但可展示的内容应该也能说明白业务架构设计的重要性了。
huaxue
这是一个华东地区的景区,我们称这个景区为“A”,它拥有滑雪场、亲子游玩项目、度假酒店、餐饮和其他一些项目。最初的方案中,我看到的客户现状和痛点分析是数据孤岛、没有私域流量运营等几个痛点,但是在方案的后续内容中对于数据打通之后客户能得到什么,私域流量如何去打造等问题并没有去做合理化解决,而是开始堆整体系统架构,开始把我们的各类产品堆进去,比如中台、比如旅游商城,还有各类综合管控系统等。各位,这并不是我们的特点,这是行业通病,而且我自己接触过的非常多的大厂架构师也不能免俗,当然,有个别几个大厂架构师是确实有真正的业务架构设计能力的,但是大厂的KPI考核也让他们会把一些客户体量实际用不上的高价产品塞进去。

那么面对这样的一个客户,我们更合理的处理方式应该是怎么样的呢?

首先需要做的是和客户更深入的交流,包括客户的决策者和实际的运营团队,然后去挖掘哪些点可能是客户的核心竞争力。在A景区的项目中,我们最终确定下来在数字化层面上的客户核心竞争力是“做好游客的复游率”——提出了“每年必来一次”这个的项目主要slogan。因为类似于A这样的景区(或度假区)在华东地区超过10家,各自的获客竞争是非常激烈的,假设每获客成本是100元(线上线下各种渠道的推广),那么增加复游率,就可以将每获客成本大大降低,比如每获客成本变成60元,这就是景区在竞争中的核心竞争力之一。

其次去分解“每年必来一次”这个主目标,一般会分成三个主要指标,这里我只举其中的一个,就是老客的差异化服务。简单列几个措施包括:

1.景区大门、休息室等门禁的人脸身份识别,会有语音反馈,比如“尊敬的X先生,欢迎您再次来XXXX”,“我们为您和家人准备了XXXX”等,体现尊荣感;

2.酒店服务上,假设普通客人是1位服务人员对应20个房间的话,老客户获得的是1位服务人员对应6个房间的服务,而且在服务、物品上也会有差异;

3.老客大礼包,包括景交车、矿泉水、停车费等方面的减免券等。

这些措施都会在老客游前就进行营销,转化率是一个检验有效性的大指标。然后再根据这些指标和措施进行具体的工具与运营动作的落实。比如在工具方面,这里就需要人脸识别系统和门禁、语音智能系统、改造过的PMS、电商会员系统和核销功能、营销效果数据分析系统等,以及作为底座的数据中心。在运营上,老客尊荣服务的设计和执行,礼品的筹备和发放、人员服务用语的培训等等,都是非常具体的运营动作。

其实在整个业务架构的设计上,“每年必来一次”虽然是最大的目标,为了实现它而设计的软硬件系统却可能只会占30%,但这已经够了。其他70%部分,依然可以是传统的安全管控、人员管理、可视化数据驾驶舱等,毕竟这些是标准件,虽然不能为项目凸显特色,但客户也是需要的。

如何做业务架构设计

接下来说说业务架构的设计步骤,也会再举一个真实的案例来配合说明。

我自己整理的设计步骤是以下五步:

1.和客户充分沟通,整理需求;

2.挖掘和定义主指标,也就是最主要的一个大目标,或slogan;

3.分解这个大目标,一般会分解成2-3个二级目标,并论证这些二级目标可以支撑大目标的实现;

4.为每个二级目标制定抓手,这些抓手是为了支撑这些二级目标的,抓手一般可以是一句话的解决方案或指标,比如“人均打开次数3.1次”、“人均停留时间86秒”等;

5.制作运营动作和工具(系统)清单,为各自的抓手服务,然后可以将一些可以共用的系统抽出来,将运营清单和功能清单合理化。

这里再讲一个案例,是分布在城市里的某沉浸式文化类体验馆的项目,我们暂称为B项目。B项目我就不说具体的过程了,直接上一些图例。

案例2

这里略过了客户沟通和需求整理,以及大目标(因为一看就能看出是哪个客户),只从二级目标开始,再到抓手,再到最后的运营动作和工具。当然,上图并不是最终版本的方案,每个二级目标有单独的分析拆解内容,这里就只放一个集合稿的截图了。

前面讲的都是从客户需求和规划阶段就开始的业务架构设计,但是很多情况是在你介入的时候,顶层的规划可能已经定义好了,这时候依然是可以进行设计,无非是从二级目标,或者抓手开始。

曾经经历过一个项目,项目负责人们对于这个运营项目需要做什么已经定义的非常清晰,而且,我也觉得确实是非常有开创性,对于客户是非常有价值的。但项目在开始的第一年却进行的非常挣扎,特别是在产品和研发工作开展上,一度是找不到契合的实施方向的。

比如其中的一个二级目标是月GMV达到300万元,之后就给到产研侧了,让产品经理去想怎么实现配套的系统设计,结局就很惨了。比较合理的方式应该是将月GMV300万元进行分解,我们从最简单的GMV公式来分解:

GMV

这里的业务架构设计就是不断去分解,我们只讲转化率,将一些更具体的支撑转化率的抓手找出来,比如小程序如果游客只打开一次,只有3、4秒,就再也不会打开了,那又怎么可能会有转化率呢。只有把这些抓手定义出来,才能给到产品经理和运营人员去思考在设计和内容上如何去实现这些指标,才不至于茫无目的地挣扎。

好了,今天就说到这里了,希望这篇文章可以帮助到哪怕一位朋友。

对了,如果要加强自己的业务架构能力,平时需要主动地多去了解一些知识和技能,比如和客户的沟通能力,需要有基础的规划能力,需要对产品设计和研发有一定的了解,还需要知道运营工作到底有哪些,换句话说,也就是不要给自己设限,主动地多做事,多争取实操机会。另外,心里永远怀揣一句话:“再好的方法,也不如真心地创造客户价值!”



关注我的微信公众号,可收到实时更新通知

公众号:土猛的员外


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!



多给提示 少做计划

新的一年开始,相信很多朋友已经开始计划这一年要干的大事情了,因为今天我也在思考。但是翻看前年、去年的年初计划,发现自己完成度是非常低的,比如列出了一年内要看完的书,要把产品做到什么程度,要学习一项新技能…但,好像近两年的完成度都没有过30%。我肯定不会把自己归为懒人,我自认为多少还是有点勤奋的^_^。那么是什么原因让自己的计划屡屡落空呢?

我觉得仅仅做计划是不够的,甚至,太过细致的计划是有害的,一旦在实际执行过程中错过了一些计划项,很可能会对信心造成打击,影响到后续计划的执行。

其实大多数的事情都贵在坚持,在这方面,2022年下半年我似乎找到了一些方法,并且获得了一些好的收获。这个方法的本身并不是计划,而是提示。

在继续往下讲之前,我想先借助一个思想工具:福格行为模型。

其核心思想是影响行为的因素有三个:动机+能力+提示,只有三位一体,才能使行为发生。就思想工具而言,是非常简单的,但真正理解之后,​会有奇效,下面展开讲一讲。

影响我们去做一件事,特别是一些需要坚持的事情,动机肯定是很重要的,但是作为成年人,动机反而可以是放在最后面的因素。而且动机这玩意儿,太不单纯了,很容易“背叛”我们,就像昨天你想为中华崛起而读书,今天想安安静静过好这一生也不错。像那句话​:“明白了很多道理,却依然过不好这一生”,真正让我们的人生道路发生改变的并不是你意识到了什么,那些真正让改变发生的更大因素恰恰是​能力和提示。

我们先来看能力

能力其实很好解释,就是完成这件事对你来说的难易程度​。我们当然会崇拜披肩斩棘、筚路蓝缕般的迎难而上,但这样事情极难持续,也就是我们说的做成一次就可以吹一辈子​。如果我们要持续坚持做好一件事,那么最好的方式是把事情拆简单一点,再简单一点​。说回计划这件事,可以做的大块一点、含糊一点,不要过于细致,太细致反而是很难执行的,因为你每天会碰到各种不得已,不要为了错过一次规划的“行为”而懊恼,也不要因为连续错过三次就放弃​。

当然,能力是需要去提升的,这会帮助我们每次做​好更困难一点的事情。提升的方式​主要有两种:
学习,刻意训练,提升技能;

借助工具、向朋友求助,以及用金钱换时间。

最重要的,我们来看提示

其实我主要想讲的就是提示,这是2022年下半年,让我获得一些实际好处的​助力因素。
先从我身上发生的两个例子说起​。

第一个例子是跑步,我是从2022年的5月份开始正儿八经跑步的,每周大概2-3次,每周跑满10公里。跑步对我这样BMI接近30的人来说肯定是有巨大好处的,动机很明确,但是光有动机没用,难道我是今年才知道这个道理吗?这些年我一直没能坚持下来​。去年坚持下来的主要原因,我觉得就是经常给自己设置提示——如果明天早晨要跑步(我一般是周二、周四和周六早上跑步),那么我就会在今晚把跑鞋、运动服和腰包放在副驾驶座。第二天一早,把儿子送到学校之后,看着这些装备,我怎么好意思不跑呢​!

就这样,我坚持了半年,目前是阳康阶段,只能到春节后再跑了​。当然,除了提示,还有一点非常重要,就是鼓励​。我会把跑步数据放在朋友圈,有不止30位朋友、同事和我当面聊过跑步的事情,一些同事最经常说的就是​“你真的瘦了”。是的,这种鼓励是很重要的,其实它也是一种“提示”,因为提示除了物品提示,还有人的提示。当然还有就是情境的提示,每次开车经过丽水路,看着运河边的绿道,那份心痒痒、脚痒痒也是让我们坚持下来的动力之一​。

第二个例子是​项目的推进。下半年我作为销售的一个项目,在内部产研推进上一开始非常不顺,这里有内外部各方面的原因。有那么一段时间我很焦虑,都有马上要​掉头发的感觉了。甚至我自己都躬身入局到产研侧,讲大道理,讲项目的重要性,讲做好这个项目对每位参与者带来的好处,在公司内部刷脸请专家帮助项目组解决问题,等等。还给项目组做时间节点计划,除了代码不写之外,貌似什么事情都亲自下场了,但无济于事。

幸好没过多久,我在小宇宙(任鑫的播客)中听到了福格行为模型,马上找书看了一下,发现确实可以解决​我面临的一些问题。于是,我也不去给项目组做什么细致到时间节点的规划了,而是几乎每天会找相关的产品经理、开发负责人聊聊天,哪怕只是一句​“怎么样了?”、​“有遇到什么问题吗?”​。我现在可以确切地说,这种每天的提示,让项目组的人的行为更加有效了。比我之前给他们做时间节点计划,然后三五天或者一周去问一次有效得多​。

这两个例子在讲的都是提示,第一个是给自己设计各种提示,让自己把跑步坚持下来​;第二个是为团队做每天的提示,让大家的行为可以均匀持续地发生着​。特别是第二个例子,我觉得去强调做好这个项目,你们以后简历上都会有一个光鲜的客户案例,这样的动机对于产研同学来说又有谁不知道呢,但是他们更多的时候是羁绊在各种压力中,迷失在随时可能产生的小挫折中,我们需要用持续的提示去让他们的行为发生,这种提示可以是关心、询问、鼓励,甚至也偶尔是小吵吵​。

说了这么多,其实并不是让大家不要做规划了,而是觉得大家更应该在这一年的历程中给自己设计更多的提示,不忘事、不​苟且!去试试看,设计各种提示,就像在你前行的道路上提前扔下各种路标、补给,让你可以科学地、​稳健地走下去——别太相信毅力这东西,我们大部分人都​不太有。​

不断提示、持续行动,2023才会​心想事成!



关注我的微信公众号,可收到实时更新通知

公众号:土猛的员外


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!



讲实话 做实事

2022年应该是疫情三年影响最大的一年,最直接的感受是自己被隔离了两次,年底自己和家人也感染了新冠,感受到了病毒的威力。这一年在工作上倒有一些值得感悟的地方,比如在几个项目操作中感受到了业务架构的重要性,比如正儿八经地做了半年销售,且还算有一些比较正向的收获。

回看这些年,在数字化文旅这个赛道,虽然没有出现巨无霸,但是竞争还是激烈的,最显著的特征就是各参与企业都在针对政策不断创新产品和价值理念,对于从业者来说,日常工作用“卷”来形容并不为过,我们都在不断创造新的理念、设计新的产品和开发新的系统。特别是下半年,我发现自己做的事情基本都是创新的,用全新的理念来服务客户,因为其他竞争对手也都在创新,这个市场就是这样。乐观来看,这说明这个赛道正在高速向前;悲观来看,这个市场,讲故事要比讲实话做实事重要。

不停地奔跑,或者叫不停地挖坑,最大的问题在于坑多但每个坑都不深。因为要挖好这些坑,除了理念和思考,还需要人,需要产品经理、运营人员、开发人员…他们就像水管里面的水,如果水管太粗,水流就只能是缓缓冒出。想要在一些事情上干的出色,建立优势,最直接的方式就是减少水管的横截面,那水流才能喷洒而出。

关于产品创新和运营

所以,2023年,我觉得要改变一下,把过去有点“沾沾自喜”的讲故事(或创新)的习惯改改。要多讲实话、做实事,具体来说就是克制广度,夯实深度。

比如,少造新理念,把现有的理念变成更好的产品,做好产品的运营和服务,在实践中学习,和客户共同成长,让现有的一些好的理念变成真正的客户价值。另外,最重要的是重视客观规律,敬畏市场的规律、敬畏产品从0到1再到n的规律。

关于SaaS

2022年另外一个巨大的感悟是在产品的SaaS市场运作上的失败。总结起来是没有渠道运作的经验、没有真正把时间花在找PMF上,也没有真正建立起配套的人员保障和工作机制。甚至,没有真正审视过这个产品是否真的适合SaaS运作。要敢于对自己讲实话:如果真的是SaaS,是不是最终会遍地开花遍地失守?是否应该先定一定数量的优质客户,然后扎下去服务好,不断为他们创造真正的价值才是更好的选择?

最后

这些问题我现在是没有答案的,但我相信讲实话做实事肯定是接下来市场的主旋律。在市场处于增量期,卖预期肯定是一门好生意;当市场增量放缓,进入存量竞争,成熟的、价值可量化的产品和服务才是好生意。

2023年第一天的感想,希望在今年每周都可以写一写自己的感悟。



关注我的微信公众号,可收到实时更新通知

公众号:土猛的员外


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!



解决方案优秀范本——《隆中对》

今天说说解决方案的事儿。

虽然自己并非解决方案工程师,但是打过交道的解决方案工程师没有一百也有八十了,见过的方案更是无数。说真的,都是方案,却有云泥之别。

好的方案思路清晰,简洁明快,就像小学一年级学aoe和汉字一样,非常易于阅读者吸收;差的方案则通篇看完不知所云,解决了什么问题?提出了什么想法?如何实施?哪怕你绞尽脑汁依然无法提炼。

那么如何写好一个解决方案呢?我觉得核心是帮客户解决问题。牢牢抓住这点,然后再用“套路”辅之,带领客户的思绪往前走,让他更容易理解你为什么能帮助他解决问题!

我不想用自己从事的行业里面的内容来讲怎么才能写好解决方案,但是我可以用一篇三国后期陈寿在《三国志》里面写的《隆中对》来看看好的解决方案是怎么写的!

《隆中对》主要内容是刘备三顾茅庐,问计如何复兴汉室,诸葛亮结合刘备现状提出的一揽子战略解决方案。

需求

先来看刘备这个甲方的需求。

甲方需求的大概意思就是我的使命是复兴汉室,但是感觉自己能力不够,希望你帮我出出主意。

解决方案

我们来看看诸葛亮是如何给出解决方案的。

我们对这段解决方案做一下分解。

  • 现状分析(背景)

诸葛亮在现状分析的背景这一章节里面写的非常简单,一句话带过。其实这也是我非常欣赏的,我们不需要把很多篇幅放在这里,因为你的服务对象在现状分析方面可能比你更加清晰,不然他们不会找你帮忙。

  • 市场竞对分析

接下来诸葛亮分析了目前市场上主要的竞争对手,因为在这个市场上,大家玩的就是零和游戏,基本上不可能做到共赢,所以分析竞对显得尤为关键。

对于两个主要竞对的分析不仅仅挑出了他们的优势,也指明了与他们竞争的方针:

  • 曹操:以弱胜强是因为会用人,现在实力最强大,而且挟天子以令诸侯,占尽天时。对于操作,肯定是刘备的主要讨伐对象;

  • 孙权:江东有长江地利,而且人心稳定,实力也不可小觑。但是如何单独和曹操对抗那也是危机重重,所以江东是可以作为联盟一起抗曹的。

  • 方案思路

这一段就是直入主题了,是一份有较高可行性的结局方案。

首先,指出方向。因为分析了北方和东面已经无法占据了,那么,现在剩下的荆州和益州。拿下这两个地方作为根据地,才有做一番伟业的基础。

其次,分析了如何执行。先解读了荆州可以取得,也必须取得的几个原因,地理位置太重要,而且这是上天给的机会(后文提到刘表将亡,而其后代没能力守),所以必须拿。而刘表去世之后的混乱,对于刘备来说是个千载难逢的机会,毕竟他现在已经在荆州了,而且你不拿,别人也要拿。后来的赤壁大战也验证了诸葛亮分析的正确性。再是分析了益州,那是刘璋本身就没有能力守,而且是刘邦当年龙兴之地,大粮仓,必须拿。在法理上,这两州都是刘家人,刘备代之,依然还是汉家天下。

最后,点出获得这个根据地之后,需要联吴抗曹。前面说了曹操挟天子以令诸侯占据天时,孙权据江东之险占了地利,那么从差异化角度来看,刘备必须要树立信义、用好刘皇叔这个身份,吸引四方英雄,打好“人和”这张牌,这是刘备差异化竞争的核心竞争力。

  • 方案价值

前面一步步的铺垫,为的是最后解决方案要实现的这个目标:霸业可成,汉室可兴。

有了荆益两州才有资本去实现这个目标,而且,诸葛亮也不是完全吹牛,也指出了有些事情,也是需要看运气的。任何事情的成功,其实都是需要运气,但是在解决方案中,我们需要明确知道,哪些是奋力争取可以实现的,哪些是需要看时机的。

这里的时机就是天下有变,其实在诸葛亮的盘算中,应该是曹操谢幕,曹氏在权力上无法牢牢控制北方。还有一种机会就是汉献帝去世,后面立的皇帝大家见都没见过,自然没有向心力,那么曹操失去了挟天子以令诸侯的手段。等到这个时机出现,两路出击,可实现霸业。

当然,对于最后这一步的解决方案思路,也有很多人提出过质疑,包括毛主席。主要的思想是魏强蜀弱,还分兵出击,不可行。

  • 最后

但我们今天只讲方案,就方案来说,《隆中对》的思路是非常清晰的,而且在方案思路部分直奔主题,毫不拖泥带水,非常清楚地讲出了甲方应该如何去做的思路。且,具有较强的可行性。

从《隆中对》中,我觉得自己学习到了这样几点:

  • 竞对分析要分、分、再分:一定要去不断地做分类,分别看问题。如果诸葛亮只看竞对,那么曹操、孙权、刘璋、张鲁,从目前来说都是敌人,那我们就不知道如何破题了。而将曹操、孙权这两个最大的竞对做区别分析,一个标记为敌人,另外一个标记为可合作的朋友。很多事情,如果我们不去分,那么都是困难,如果我们学会分,就能看到困难也不一定都是困难;
  • 要站在甲方角度去解题:我们现在很多方案都是从乙方角度去思考的,给甲方指出很多问题,然后接下来就是我有药,来,给你开个房子,药房拿药(产品)。但是现在郎中太多了,甲方需要是真正能解决问题的郎中,而不是你所谓的祖传秘方;
  • 方案一定是以终为始的:一定要有一个终极目标,这个方案是为了什么价值,最好可以用一句话说清楚。然后,一切的思考和执行计划,都是围绕着这个目标展开的。不然,方案很容易就变成啰啰嗦嗦,却言之无物。

当然,最后的最后,可执行性依然是最关键的,这就需要去考量市场的变化,以及企业自身能力的变化,确实需要很综合的能力。


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!



景区数字化营销创新玩法

本文会讨论的以下几个问题:

  • 为什么景区数字化营销无法落地?
  • 是否真的存在创新的方法来解决景区精准营销问题?
  • 从务实的角度来看,我们应该怎么做有效尝试?

在我的记忆中,从2015年开始整个行业就已经在关注景区的营销问题,那时候叫“智慧营销”,属于“智慧景区”或“智慧旅游”里面的一大组成板块。但是直到2022年,我也没有发现在景区游客营销方面非常有说服力的案例,特别是在山水类景区(游乐场类型的还是有个别案例的,但是更多手段的并不是数字化营销或所谓的智慧营销)。当然,不否认我孤陋寡闻,大家可以留言拍砖。还要一点要强调的就是,本文指的营销是指除景区门票、索道等项目之外,如景区内的收费服务、收费游玩项目、文创、餐饮和住宿等消费项目的营销。

那么为什么在电商、金融等其他行业已经非常成熟的数字化营销在景区应用中就水土不服呢?我相信要剖析这个问题,首先需要从景区的固有特点来切入分析。

下面我列了5个造成景区数字化营销失败的可能的原因。

  1. 数据维度缺失

在电商行业的几个头部应用上面,采集到的用户数据维度是极其丰富的,比如淘宝/天猫、京东、拼多多等。特别像淘宝,用户的个人信息,对商品的喜爱偏好数据,购买力的推测,根据购物车和收藏等数据的预判。以及你在某个产品页上看了又看,推测出来的价格敏感度或某些功能特性的担忧,一应俱全。再可能结合支付宝在生活服务和金融方面的覆盖,结合高德在出行方面的覆盖,完全可以推测出用户的收入层次、职业、家庭成员数量和品味等方面的信息。所以,基于内容的推荐,基于大数据协同过滤的推荐,以及通过贝叶斯、隐马尔可夫等算法为基础的预测分析,可以有效地形成精准营销的能力。

而我们回到景区的现状,这些年在数字化层面上确实有进步,至少实名预约解决了游客身份信息和手机号码数据的收集。在这之前,游客身份信息数据收集几乎也是非常不全面的。就像早些年让我印象深刻的是绍兴的鲁迅故里-沈园景区,他们将其中的鲁迅故里(三味书屋、百草园等)作为免费景区,但前提是游客需要刷身份证入园,目的是为了收集游客数据,以便在游客身份信息,比如客源地、年龄、性别等。这些数据确实可以有效地帮助景区在门票渠道上实现更精准的价格策略,以及在市场营销上更有目的性。但,这些数据对于游客的数字化营销有多大价值呢?很直接的一个问题:你可以在游客想吃臭豆腐的时候推荐他最正宗的店,或者给他优惠券吗?

所以,第一个困难点就是景区依然缺少对游客更精确的数据维度收集。

  1. 营销窗口期短

在淘宝等平台上,用户会经常性地拿出手机登上去看看,而且很多时候已经成为人们在放松时段打发时间的方式,天天可以拆快递的人生才是有盼头的人生,专业上说就是月活数据非常高。所以,这些平台的营销窗口期是非常长的。

但是景区呢,我可以负责任的说,大部分景区是没有回头客的。哪怕是很多著名5A景区,对于大部分游客来说也就是一辈子只有一次交集的机会。也就意味着在游客和你发生连接的时间段内如果无法完成营销,那也就没有机会了。

我们可以用水池和水管来形容两者的区别,淘宝等电商平台的用户是可以进入蓄水池的,有足够的时间来做营销转化;而景区的游客更像是水管里面的水,你无法在水通过这段管子的过程中实现营销,那就没有机会了。所以,传统的RFM模型(衡量客户价值和客户创造利益能力的重要工具),以及大数据分析,都会显得时效性太慢,就像用自行车是无法追上高速行驶的高铁的。

水池
  1. 缺乏触达手段

淘宝、京东等APP本身就是服务商触达用户的渠道,营销可以在其上面完成。但是景区在触达手段上却显得非常贫乏,即使现在通过公众号和小程序购票已经非常普遍,但是游客在心里就是抵触你的营销的,所以买了门票、索道等刚需服务之后,基本上不太会再次打开景区的应用。

当然,现在很多景区对这个问题也非常重视,所以通过景区地图导览、语音讲解等游客服务应用,确实也在增加游客打开景区应用的频次。但是问题又来了,游客还是天然有抵触其他营销的情绪,那么数字化营销如何继续?

  1. 景区服务和游客需求不匹配

在电商APP上,我们往往可以被推荐哪些我们确实有需要的产品,甚至未退出之前的亚马逊,对于图书的推荐我是感到惊讶的。有一些我确实想找,但是我不知道怎么输入搜索关键字的书,总能被系统一次次地推荐到我面前,我会如获至宝,马上下单。

但是在景区服务中,我们知道游客的需求吗?他在景区的某个区域,某个时段,会有什么样的需求,我们能知道吗?而且我们可以将确实符合他们需求的服务和产品推荐给他们吗?这种服务和需求匹配上的鸿沟,让我们在对游客进行数字化营销的时候困难重重。匹配上了,那是瞌睡了就送来了枕头,如果没匹配上,那就和美发店里面的推销一样,烦透了。

所以,要做好数字化营销,如何感知游客心里那瞬时的想法非常重要。

  1. 缺乏服务和产品亮点

这一点我其实不太想谈,因为在这一块问题的解决上,数字化能力也许不是最佳解题关键,景区自身的产品打造、IP打造、热点制造才是关键,包括有口碑的产品,有本地鲜明特色的产品等等,是更加重要的解题手段。

但是数字化也许多少也能帮上一些忙。这里讲一个我们在实操中的案例。比如在我们服务的一个国内著名头部景区的过程中,多次向景区提出数据分析上得出的一些建议,其中包括平均每个月有近300次关于哪里可以寄存行李的咨询。在今年年初,该景区终于上线了行李管家应用和一系列的服务。

再比如,在分析某景区的主要游客诉求是拜佛、祈愿之后,帮助景区包装了一款热门文创产品,用一句“不要临时抱佛脚,带个佛脚杯回去每天抱着喝水”,将文创产品和游客诉求很好地结合了起来。

所以,数字化需要帮助景区去发现游客会有什么样的密集需求,帮助景区去提升产品和服务的亮点。

说了这么多,无非是为了说明电商和金融等领域已经证明非常成功的数字化营销方式并不适合景区。那么,景区应该如何去做数字化营销方面的创新呢?

要在景区数字化营销上做到创新和实效,那必须先摆正价值观,要真正给游客提供他们需要的价值。

我觉得所有的创新都是为了游客获得更大的游玩体验,我们可以从以下三方面展开:

  1. 帮助游客可以舒心地游玩,解决他们旅途中的焦虑,比如“最后一班回去的车是什么时候?”,“附近哪里有厕所?”这些问题都不应该成为问题;
  2. 要真心去服务游客,就像上面提到过的,我想瞌睡你要给我送枕头。这就需要了解游客心里的需求,然后快速反馈,满足游客需求。真心的服务才是完成数字化营销的根本;
  3. 要让游客在游玩过程中有获得感,除了拍照、拍照还是拍照之外,对于一些文化历史浓厚的景区,可以将文化比重加大,好的文化输出是最能引起游客共鸣的。

在这些方面,作为混迹在文旅行业多年的退役程序员,我也一直在探究。也许今天我们先不对第三点做回答,但是对于第一、第二点,以及上面提出的五个存在问题,我给出的创新解决方案是AI游客服务助手

对于游客来说,他感知到的是一个好用的、有什么问题都能帮助他提供很有价值的答案的微信小程序。可以帮助他解决旅途中的绝大多数焦虑,可以在他有需求的时候提出问题,得到满意的答案和可以匹配他需求的服务推荐。

对于景区来说,游客在提问时采集的定位数据(GIS数据)、时间,和最重要的文本数据,是可以被进行实时分析的。比如游客在早上7点钟问附近有永和大王吗?这不就已经是把需求告诉我们了吗,他现在的需求是吃一顿高质量的早餐。如果恰好附近有永和大王,那么推荐给他地址,如果没有,是否可以推荐给他另外几家附近非常不错的餐点店的优惠券呢?其实这比传统的大数据多维度要更加精确,而且可以实时分析和反馈,做到我们上面说的在水管内就把数字化营销完成掉。

另外,AI游客服务助手的使用过程中,游客是主动咨询,及时得到回复,也有了一个非常完美的营销触达渠道。我们尽量不要想着用短信等方式去“骚扰”游客,一方面你无法精确找到这个游客,另外一方面短信这种主动营销方式被拒绝的概率极高。

当然,我要泼盆冷水,要做好这个AI游客服务助手也不是简单的事。

AI能力本身就比较难处理,现在市面上多数的AI问答应用的回答能力都很弱,命中率可以达到60%以上的已经不多,何况还有回答质量上的考究。造成这一现象其实主要有三方面原因:

  1. AI技术达不到要求:目前市面上传统的NLP相关应用多以关键字匹配算法作为基础,知识库条目一多,就会造成极大的冲突问题,回答变得牛头不对马嘴,显得非常弱智;
  2. 没有深厚的知识库储备:文旅行业的知识库不好做,因为它是非常开放的,你不知道游客会问什么,所以市面上大多数的游客问答产品的命中率极低。要解决这个问题,一是要对文旅行业有非常深入的了解,二是要经历很长的知识库储备和建设周期,没有经历过亿级的咨询量,很难沉淀出一套有效的知识库;
  3. 游客服务场景上需要有深度优化:游客在景区的动线上各地点会有什么需求,在各时间段会有什么需求,以及他真正需要的是这个服务吗?这些问题都不是拍脑袋就能获得的,纯靠行业经验都不够,这一点我是深有感触的。因为在沉淀海量数据之后,我们的分析让一些资深的景区老旅游人都感觉到惊讶:居然有这么多游客在这里会提出这样的需求!

好了,到这里,我要介绍一下我们的一款产品:力石小知。我相信如果你有景区数字化营销方面的困惑的话,它是值得你拥有的。因为上面提出的一些问题和创新方法,它都已经实现了,而且部署方便,价格实在很友好。

我就贴两张产品图片吧:

力石小知1

力石小知2

力石小知3

力石小知4

如果你有需求,可以联系我。



关注我的微信公众号,可收到实时更新通知

公众号:土猛的员外


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!



PLG,SaaS产品的GTM

因目前正在负责公司的一款SaaS产品(力石小知),所以最近半年对SaaS产品的GTM(市场策略)关注的比较多。趁着这次五一假期,把所学整理了一下。加上今年春节回来之后在力石小知上的各种实践,以及为接下来紧张的市场推广做战前准备,于是决定写一写PLG,希望可以和团队及公司同事进行充分地交流,另外也可以得到外部SaaS行业前辈们地指点。

plg1

一、什么是PLG?

PLG即Product-led Growth,产品驱动增长模式,简单说就是用户(对我们来说就是客户)在专业销售团队介入之前便能自主试用甚至付费的模式。而一般意义上的PLG,多数指的是通过开源社区、免费或试用产品去大面积获取用户,然后逐步转化成付费客户的一种模式。

但是PLG本身的核心不是免费,而是:不需要销售介入、用户通过自助服务(Self-Service),1-2分钟就迅速get到产品使用价值。(从这点上看,是不是有点像2C的推广方式,用在了2B的推广场景)

针对市面上的误区总结了一个公式:PLG≠免费≠补贴获客≠B2C≠小微企业是客户≠个人决策。

二、为什么我觉得PLG代表未来的销售模式?

如果我们讨论的市场是在美国,那么我们可能都没有讨论的意义,因为至少在目前的美国,PLG是非常火爆的。2022年2月,根据Enterprise Tech 30对全球103家风投基金的采访,做出了一份早期融资阶段SaaS公司的榜单,30家公司里有25家都是PLG模式The Corp Dev 10名单里的公司也全是PLG典型代表。

虽然有很多会认为PLG,甚至SaaS是不符合国情的,但是在国内,也有蓝湖(2021.11/C+轮/10亿元)、来画(2021.10/C轮/2.66亿元)、创客贴(2022.1/B轮/数千万美元)和Apifox(2022.1/Pre-A轮/3000万元)等30多家企业通过PLG在大力发展并获得很好地融资。

而我之所以看好PLG的增长模式是因为我想通了下面这三点:

  1. 列车已经启动了:就像上面说的,PLG并不是远在天边,而是已经在国内开始发展,只是现在更多聚焦在开发工具、设计工具和项目管理等领域,但是行业会慢慢延展开的,就像各行各业也在逐渐接受SaaS一样;
  2. 数字化升级不可逆:国内近几年以及未来十年必将会持续的数字化升级为社会和企业带来了巨大的数字化基础,基于数字化的营销、基于数据驱动的营销是不可逆的趋势,而社会发展过程中一次次证明,效率更高的生产方式往往会成为最终的胜者;
  3. 销售的本质是价值交换:不管是软件还是服务,都只是一种载体或者媒介,其本质是将使用价值传递给需要的客户,换取收益价值。PLG天然的方式就是让客户快速理解价值,为价值买单。哪怕在2G市场无法彻底地靠使用价值去销售,但是我相信以使用价值为导向的销售方式最终肯定会脱颖而出。

所以,我相信作为当下的产品人,相信SaaS,并且去学习PLG这种未来的产品销售模式,并把它们应用到各个行业中去。

三、如何PLG?

上面把PLG讲的这么好,那么来说说如何做PLG吧!

3.1 PLG是一种整体打法

在说如何做PLG之前,必须再进一步介绍一下它。

PLG全貌

我们可以看看上面这张图,我相信你看到了6大部分:市场、增长、产品、客户成功、销售,以及贯穿全程的数据洞察。对于我们公司来说,这至少连接了三个中心的工作:营销中心(销售和市场一部分),运营中心(市场一部分、增长、客户成功和数据分析),以及研发中心(产品)。所以在做PLG之前请先梳理好产品线的组织架构,可以是虚拟组织(我们就是虚拟的,结果有待验证),更优的方式肯定是真正的统一。

PLG不是哪一个部门或中心来牵头的,不是营销中心说我想这么做,运营中心说我想那么做,而是必须明确总负责人,列出北极星指标(衡量成功的最大目标)。在很多公司的分享案例里面,这个北极星定的都是使用量。非常巧合的是,力石小知的最大指标也是使用量(主要包含使用人数、使用次数、人均使用次数、命中率等多个指标的权重组合),这是公司核心高层一起参与拍板确定的。

所以,在开始PLG之前,确定好北极星指标,明确总负责人(带方向和背锅的人),负责人最好是了解以上各部分能力的。而且要有数据分析能力和数据驱动业务的思维。

3.2 PLG的有哪些打法?

a. 开源社区

PLG的打法非常多,常见的也是最让人能对的上号的就是开源,或者相应的免费版、试用版。但是我必须负责人的说,不是什么产品都可以开源!根据我自己的观察,能开源的产品一般都是自下而上的,也就是会有实际用户先开始用。在其获得一些使用上的”快感“后,给团队(协作)或公司层面去推广,然后形成公司(组织)级别购买。也就是很多SaaS公司分享的三层Aha Moment的Activation。

像我们公司使用得蓝湖、墨刀和语雀等,都是从UI设计师、产品经理和研发工程师(有一部分研发工程师用的是Notion)中开始流行,并最终让公司购买。这里提到的UI设计师、产品经理和研发工程师,其实都是有自己的社区的,好的产品是很容易形成传播的。

b. 互联网内容推广

PLG的另外一种打法是各种软文推广。其实软文是不准确的,因为大多数内容确实应该是对客户有价值的,比如在我们的力石小知产品推广中,我们会写《景区如何在游客低频消费的场景下增长二销收入》来帮助我们的目标客户,这是有实际价值的。

这类推广的主战场是百度等搜索引擎的SEO优化,知乎的”泻药“评测,以及抖音的知识内容输出。说白了,就是在互联网上增加你的产品影响力,让有需求的客户找到你的官网,进行试用,进而有机会成为你的真正客户。

c. OXM

有一些SaaS软件非常好的借助了云原生的优势推出了云版本,比如数据仓库公司Snowflake和Spark推出的AWS版本,既能在AWS上收到分润,又可以让很大一部分用户获得”爱的初体验“,继而有机会使用他们更多的服务。所以OXM是一个好的方向,我们也在考虑阿里云、腾讯云和华为云等厂商。但是在OXM的时候,记得保护好知识产权。

d. 传统销售

大多数PLG产品还是需要有传统的销售动作的,毕竟你是需要获得第一个客户的,而且早期的客户最好是行业的标杆客户。2019年,在力石小知还是PPT的时候,我非常有幸可以和普陀山的业主讲解产品理念,并获得他们的认可,而后我们才进行了产品的研发。之后是颐和园、西溪湿地、天台山和三河古镇等一批5A景区,这些产品的使用也使得我们的PMF更加清晰,并形成了一套标准产品的销售方案,最终由营销中心对外进行销售。

在SaaS产品销售中,标杆客户的势能是非常重要的,可以为你带来以下几个好处:

  • 相对广泛的产品应用场景和需求,有助于产品快速成型;
  • 市场影响力背书,有势能优势;
  • 持续打磨产品细节,并发现更多增值服务可能。

所以,有好的标杆客户是非常重要的,而第一批标杆客户往往都需要传统销售去接触,大多数时候,如果你是产品负责人,那么这个传统销售的动作就需要你自己去完成。

e. 会销与理念输出

很多时候SaaS代表的不仅仅是一种工具或服务,而是一种新的理念,工具背后的方法才是最重要的。就像”先进团队,先用飞书“,突出的是一种新的团队协作方式;而Notion是在将一切Page化,一切Block化,让所有工作的事情都在Notion里面去解决,打造一个团队一个平台的理念。另外,有赞在起步的时候,经常看到白鸦的朋友圈里面发各种城市沙龙或会销,其实首要的也是在输出有赞的商家服务理念。

力石小知除了基础的AI游客问答外,还有游客需求洞察分析,我们倡导的是从“心”去服务游客:实时了解游客心里在想什么,实时分析他们的需求并给出匹配的服务。所以力石小知让景区有机会去提升服务水平,让景区有机会实时对游客进行精准营销。除了这些之外,力石小知还非常注重其他一方、二方数据的整合利用,力图打造景区的商业数据OS。

正所谓,工具虽犀利,理念永流传!在会销的时候做理念输出,也是一种非常好的PLG方式,只是会销的时候来的可能是老板或者高级领导,不一定是自下而上的方式。

3.3 一开始就需要建设商业化路径

社区以及免费试用的效果可能会很好,但是它也是有明显的副作用的,就是一味的免费,我们是赚不到钱的。

所以,做PLG,还是要在一开始就想好你的商业化路径的。完全免费的产品策略,不仅区分不出会付费的用户群体,甚至可能影响产品在中大型公司里自然扩展的速度。

如果说的更加伟光正一些,商业化与定价的本质并不直接是收入。早期快速推出商业化,可以开始积累商业化的数据,积累相应的付费用户的行为数据,这个数据才是真正重要的(还好,我们现在已经有整套的数据看板了)。

a. 那么什么时候可以收费?

根据著名创投人安德森霍洛维茨(A16Z)的文章《Bottom-Up Pricing & Packaging: Let the User Journey Be Your Guide》中说的:

当你在一个核心用户群中看到可重复使用的模式的时候(必须是数据驱动方式分析出来的),或者当你通过不断的客户访谈发现了一些客户愿意付费的功能的时候,就可以开始考虑收费了。

在国内,早期的商业化一般都是客户推动的,最常见的比如技术支持,单独部署的服务器,高并发和负载均衡等。

b. 的目的是为了收集客户画像和产品反馈

免费用户和付费客户的使用习惯往往是不同的,而且,付费客户更有可能是深度使用的用户。这个时候,如果你已经有完善的数据分析系统,就可以对这些付费客户的使用行为数据进行收集,知道他们的困难点在哪里?知道他们在乎的点在哪里?当然,付费之后,他们更加在意使用体验,他们也更乐于反馈,而且他们也有这个权利。对于他们的反馈肯定会优先受到重视。

c. 定价的方式

PLG产品的定价方式很多,但是我这里介绍一种Land and Expand的定价方式。Land代表了基础产品和服务,具有普适性,价格亲民容易上手。Expand在传统意义上讲就是增值服务,而且我建议最好的Expand是从分析你的付费客户使用习惯和需求中得出来的。

Land and Expand也有两种方式:

  1. 使用量收费:有一个初始包,超过就需要额外付费;
  2. 增值服务:通过不断叠加的服务收费。

两种方式各有利弊,1似乎更加收到资本市场的青睐,因为NDR(净收入)更高,但2在一定时间之后护城河也许更强大。

去设计这些商业化路径的时候不是拍脑袋想出来的,而是要用数据驱动,切记用数据驱动!

3.4 PLG需要坚持的一些原则

a. 与最挑剔的客户打磨你的产品

说说当下市面上最成功的SaaS产品之一Figma,Figma的CEO在一次播客的访谈中说到Airbnb。Airbnb对产品质量和功能的要求都非常苛刻,但是我们知道,他们代表了设计师的最高水准,如果产品能满足他们的用例,那基本上的客户需求也都能够被满足了。

从Figma的customer stories(客户故事)页面中,你会发现Figma跟这些客户的早期合作有两个特点:

1). 找到大家共通的痛点,这样根据一两个标杆客户开发的产品就可以很快标准化。

2). Figma把产品与客户共同成长做到极致。

对于所有B2B 公司,最开始的几个客户,对于公司产品方向的影响其实极大。敢于直面自己产品的不足,敢于服务最挑剔的客户,当然比服务一些要求不高的第三方公司、或者只要服务就给钱的传统企业,难得多。

力石小知在发展中有标杆客户,但是也许是客户对我们太温柔,所以我自感成长速度是不够快的。直到,我们遇到的长隆…这是一个非常挑剔的客户,有那么一段时间,我们觉得是被按在地上摩擦的,也许这样打磨产品的速度会更快的^_^。但是经过这几个月抠细节抠到标点符号的磨合,力石小知明显地成长了一大步。

b. 早期的sales应该是咨询功能大于售卖功能

PLG早期需要的销售更多不是传统型的,而是Renaissance Reps(文艺复兴的代表), 就是说在你刚刚找到PMF的时候,你需要的不是成熟的sales经验,而是聪明、学习能力强、对产品有理解的年轻人,跟你的产品、市场等团队,一起去做最早期的客户沟通和服务,打磨出最初的一套话术和销售模式,为日后的扩张打好基础。

咨询背景+一两年销售经验的小伙子是最适合的。他们的重点是发现,而不是销售指标。

他们更像是特种部队或者侦察兵,把进攻的计划基本摸清楚了,后面大部队(传统销售)来分解销售指标拿结果。

c. 再强调都不过分的数据驱动

顶尖的PLG公司运营者们最常提起的,就是experiment, experiment, experiment(这里翻译成数据验证最合适)。核心是data-driven的运营模式和self-service时候的引导过程。

这里再复习一下self-service,其实在文章开头讲过:就是PLG产品的核心是Activation(激活用户),不需要sales介入、用户通过self-service,1-2分钟就迅速get到产品的价值。在几乎没有sales干预的情况下,客户自己就能感受到这几个Aha moment(可以理解为:原来还能这么玩,好爽!)。

PLG行业如此依赖于数据驱动,于是就催生出了Martech行业,目前国内像神策、诸葛IO、友盟、GrowingIO等在未来肯定是有大把机会的,如果PLG抬头,比重大过SLG(传统销售方式)的话。

image-20220505002852189

3.5 最后说说生态

Shopify是一个把生态做得非常厉害的SaaS服务商,如今85%的商家安装至少一个第三方应用,平均一个商家安装了六个应用 。同时,至少推荐一个成功客户的合作伙伴数量在2020年几乎比往年翻了一倍。

2020年生态系统内所有第三方参与者获得的总收入达到125亿美金,是Shopify自身收入的四倍。不仅如此,去年公司进一步加大了对激励计划的投入,宣布取消对在平台上获取年收入少于一百万美金的开发者的抽佣。

力石小知一直有做生态的想法,我们也在打造自己的应用市场,也许在2022年不会启动。但是在2023年,生态+Tie Application(涉及未来计划,不解释是什么东东了)应该会成为一个不容忽视新的增长点。

四、结尾

其实SaaS和PLG方面要说的内容还有很多很多,后面可以慢慢讲,而且随着力石小知市场动作的推进,我相信可以有很多实践的内容与大家分享。

好了,这应该算是我五一的一大成果了,好几年没写这么长的文章了。谢谢您看完了这篇文章,方便的话可以关注我一下!

五、引用

M小姐研习录:《13000字的Figma研究笔记,聊聊Product-Led Growth的误区与思考》

我思锅我在:《2021年全球SaaS IPO不完整启示录》

兜里有糖甜:《【万字长文】SaaS增长新趋势:产品驱动增长PLG》

36氪Pro:《从海外火到国内,PLG还有哪些机会?丨36氪新风向》


TorchV AI支持试用!

如您有大模型应用方面的企业需求,欢迎咨询!