Ryuu 的个人博客

一个计算机初学者

当你从手动管理内存的语言(比如C或者C++)转换到具有垃圾回收功能的语言时,工作会更加容易,因为当对象使用完毕后将会自动回收。但是,这并不代表着程序员再也不需要考虑内存管理的事情了。

考虑以下这个简单栈实现的例子:

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
// Can you spot the "memory leak"?
public class Stack {
private static final int DEFAULT_INITIAL_CAPACITY = 16;
private Object[] elements;
private int size = 0;

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

public void push(Object e) {
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);
}
}

这段程序(泛型版见26条)中并没有很明显的错错误。但其中隐藏着一个问题。不严格的讲,这段程序存在”内存泄漏”,随着内存的占用不断增加,程序的性能降低将会越来越明显。在极端的情况下,这种内存泄漏会导致磁盘交换(Disk Paging),甚至导致程序失败(OutOfMemoryError),虽然这种情况相对少见。

错误在于,如果一个栈先是增长,然后再收缩,那么,从栈中弹出来的对象将不会被当作垃圾回收,即时是使用栈的程序不在引用这些对象,它们也不会被回收。因为,栈内部维护着对这些对象的过期引用(obsolete reference)。所谓的过期引用,是指永远也不会在被解除的引用。在本示例中,凡是在elements数字的”活动部分(active protion)”之外的任何引用都是过期的。活动部分是指elements中下标小于size的那些元素。

在支持垃圾回收的语言中,内存泄漏是很隐蔽的(称这些内存泄漏为”无意识的对象保持(unintentional object retention)”更为恰当)。如果一个对象引用被无意识的保留起来了,也会有许许多多的对象被排除在垃圾回收机制之外,从而对性能造成潜在的重大影响。

这类问题的修复方法很简单:一旦对象引用已经过期,只需清空这些引用即可。对于上述例子中的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;
return result;
}

Ryuu: elements的中的元素本身就和size不相关,仅是返回个值栈末尾的元素当然不会被回收,要将elements对其元素的引用置空,让元素没有被指针指向,该元素才会被当作垃圾回收。

以下是 Ryuu 的示例代码,您可以打断点尝试,不置空元素确实不会被回收

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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
import java.util.Arrays;
import java.util.EmptyStackException;

public class Rule6 {
public static void main(String[] args) {
Stack stack = new Stack();
for (int i = 0; i < 10; i++)
stack.push("1");
for (int i = 0; i < 10; i++)
stack.pop(); // break point
System.out.println("complete"); // break point
}
}

// Can you spot the "memory leak"?
class Stack {
private static final int DEFAULT_INITIAL_CAPACITY = 16;
private Object[] elements;
private int size = 0;

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

public void push(Object e) {
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);
}
}



class Stack {
private static final int DEFAULT_INITIAL_CAPACITY = 16;
private Object[] elements;
private int size = 0;

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

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

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

/**
* 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);
}
}

清空过期引用的另一个好处是,如果它们以后又被错误的引用,程序就会立即抛出NullPointerException异常,而不是悄悄地错误运行下去。尽快地检测出程序中的错误总是有益的。

当程序员第一次被类似这样的问题困扰的时候,它们往往会过分小心: 对于每一个对象引用,一旦程序不再用到它,就把他清空。其实这样做并没有必要,会把程序弄得很乱。清空对象引用应该是种例外,而不是一种规范行为。消除过期引用的最好的方法是让包含该引用的变量结束其生命周期。如果你是在最紧凑的作用范围内定义每一个变量(见第45条),这种情形就会自然而然地发生。

内存泄漏常见的三个来源

1. 自行管理内存类。

一般在这种情况下,程序员应警惕其可能的内存泄漏问题。一旦元素被释放掉,则该元素种包含的任何对象引用都应该被清空。

2. 缓存。

一旦将对象引用放到缓存中,很容易就会被遗忘掉,从而使得它不在有用之后很长一段时间内仍然留在缓存中。

3. 监听器和其他回调。

如果实现了一个API,客户端在这个API当中注册回调,却没有显式的取消注册 ,那么除非你采取某些行动,否则它们就会积聚。确保回调立即被当作垃圾回收的最佳方法是只保存它们的弱引用(weak reference),例如,只将它们保存成WeakHashMap中的键。

由于内存泄漏通常不会表现成明显的失败,所以它们可以在一个系统中存在很多年。往往只有通过检查代码,或者借助于Heap剖析工具(Heap Profiler)才能发现内存泄漏问题。因此,如果能够在内存泄漏发生之前就知道如何预测此类问题,并阻止它们发生,那是最好不过了。

一般来说,最好能重用对象,而不是在每次需要的时候就创造一个相同功能的对象。重用方式既快速又流行。如果对象是不可变的(immutable)(见第15条),它就始终可以被重用。

作为一个极端反面的例子,考虑下面的语句:

1
String s = new String("stringgette"); // DON'T DO THIS!

该语句每次执行的时候都创建一个新的String实例,但是这些创建对象的动作全都是不必要的。传递给String构造器的参数”stringgette”本身就是个String实例,功能方面等同于构造器创建的所有对象。如果这种用法是在一个循环中,或者是在一个被频繁调用的方法中,就会创建出成千上万不不要的String实例。

改进后的版本如下:

1
String s = "stringgette";

这个版本只使用一个String实例,而不是每次执行的时候都创建一个新的实例。而且,它可以保证,对于所有在同一台虚拟机种的代码,只要它们包含想用的字符串字面常量,该对象就会被重用 [JLS, 3.10.5]

对于同时提供了静态工厂方法(见第1条)和构造器的不可变类,通常可以使用静态工厂方法而不是构造器,以避免创建不必要的对象。例如,静态工厂方法Boolean.valueOf(String)几乎总是优先于构造器Boolean(String)。构造器在每次调用的时候都会创建一个新的对象,而静态工厂方法则从来不要求这样做,实际上也不会这样做。

除了重用不可变对象之外,也可以重用那些一直不会被修改的可变对象。下面是一个比较微妙,也比较常见的反面例子,其中涉及可变的Date对象,它们的值一旦计算出来之后就不在变化。这个类建立了一个模型,其中有一个人,并有一个isBabyBoomer方法,用来检验这个人是否为一个”baby boomer”(婴儿潮时期出生的婴儿),也就是检验这个人是否出生于1946-1964年。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// DON'T DO THIS!
public class Person {
private final Date birthDate;

public Person(Date birthDate) {
this.birthDate = birthDate;
}

// Other fields, methods and constructor omitted
public boolean isBabyBoomer() {
// Unnecessary allocation of expensive object
Calendar gmtCal = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
gmtCal.set(1946, Calendar.JANUARY, 1, 0, 0, 0);
Date boomStart = gmtCal.getTime();
gmtCal.set(1964, Calendar.JANUARY, 1, 0, 0, 0);
Date boomEnd = gmtCal.getTime();
return birthDate.compareTo(boomStart) >= 0 && birthDate.compareTo(boomEnd) < 0;
}
}

isBabyBoomer 每次别调用的时候,都会新建一个Calendar ,一个 TimeZone 和两个 Date 实例,这是不必要的。下面的版本用一个静态的初始化器(initializer)

,避免了这种效率低下的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Person {
private final Date birthDate;
private static final Date BOOM_START;
private static final Date BOOM_END;

static {
Calendar gmtCal = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
gmtCal.set(1946, Calendar.JANUARY, 1, 0, 0, 0);
BOOM_START = gmtCal.getTime();
gmtCal.set(1964, Calendar.JANUARY, 1, 0, 0, 0);
BOOM_END = gmtCal.getTime();
}

public Person(Date birthDate) {
this.birthDate = birthDate;
}

// Other fields, methods and constructor omitted

public boolean isBabyBoomer() {
return birthDate.compareTo(BOOM_START) >= 0 && birthDate.compareTo(BOOM_END) < 0;
}
}

改进后的Person类只在初始化的时候创建Calender,TimeZone和Date实例一次,而不是在每次调用isBabyBoomer的时候都创建这些实例。如果isBabyBoomer方法被频繁的调用,这种方法将会显著地提高性能 (作者: 在我的机器上,每调用一千万次,原来的版本需要32 000ms,而改进后的版本只需 130ms)。除了提高性能之外,代码的含义也更加清晰了。把boomStart和boomEnd从局部变量改为final静态域,这些如期显然是被作为常量对待,从而使得代码更易于理解。但是,这种优化带来的效果并不总是那么明显,因为Calender实例创建代价特别昂贵。

如果改进后的Person类被初始化了,isBabyBoomer方法却永远不会被调用,那就没有必要初始化BOOM_START和BOOM_END域。通过延迟初始化(lazily initializing)(见第71条),即把对这些域的初始化延迟到isBabyBoomer方法第一次被调用的时候进行,则有可能消除这些不必要的初始化工作,但是不建议这样做。正如延迟初始化中常见的情况一样,这样做会使方法的实现更加复杂,从而无法将性能显著提高到超过已经达到的水平(见55条)。

在本条目前面的例子中,所讨论的对象显然都是能够被重用的,因为它们被初始化之后不会再改变。其他的情形则并不总是这么明显了。考虑适配器(adapter)的情形 [Gamma95, p.139],有时也叫做视图 (view)。适配器是指这样一个对象:它把功能委托给一个后备对象 (backing object),从而为后备对象提供一个可以替代的接口。由于适配器除了后备对象之外,没有其他的状态信息,所以针对某个给定对象的特定适配器而言,它不需要创建多个适配器实例。

例如,Map接口的keySet方法返回该Map对象的Set视图,其中包含该Map中所有的键 (key)。看起来好像每次调用keySet都应该创建一个新的Set实例,但是,对于一个给定的Map对象,实际上每次调用keySet都返回同样的Set实例。虽然返回的Set实例一般是可改变的,但是所有返回的对象在功能上是等同的:当其中一个返回对象发生变化时,所有其他的返回对象也要发生变化,因为它们是由同一个Map实例支撑的。虽然创建KeySet视图对象的多个实例并无害处,但也无必要。

在Java 1.5发行版本中,有一种创建多余对象的新方法,称作自动装箱 (auto boxing),它允许程序员将基本类型和装箱基本类型 (Boxed Primitive Type) 混用,按需要自动装箱和拆箱。自动装箱使得基本类型和装箱基本类型之间的区别变得模糊起来,但是并没有完全消除。它们在语义上还有着微妙的差别,在性能上也有着比较明显的差别 (见第49条)。考虑下面的程序,他计算所有int正值的总和。为此,程序必须使用long,因为int不够大,无法容纳所有int正值的总和:

1
2
3
4
5
6
7
8
// Hideously slow program! Can you spot the object creation?
public static void main(String[] args) {
Long sum = 0L;
for (long i = 0; i < Integer.MAX_VALUE; i++) {
sum += i;
}
System.out.println(sum);
}

这段程序是正确的,但是要比预期的情况要更慢,因为这里打错了字符大写了L。变量sum 由 long类型变为了Long类型,意味着程序构造了231个多余的Long实例。将sum的声明从Long改为long (作者: 在我的机器上运行时间从43秒降低到了6.8秒)。结论很明显:要优先使用基本类型而不是装箱基本类型,要当心无意识的自动装箱。

以下是我自己尝试的代码 时间分别是 50 6598 ms 和 50 827 ms 性能差了约10倍。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private static long TIME_SPEND = System.currentTimeMillis();
private static long TIME_SPEND_TOTAL = 0;

public static void main(String[] args) {
// Avoid unnecessary boxing and unboxing.
// Long sum = 0L; // TEST_COUNT = 50 6598 ms
long sum = 0L; // TEST_COUNT = 50 827 ms
int TEST_COUNT = 10;
for (int j = 0; j < TEST_COUNT; j++) {
sum = 0L;
for (long i = 0; i < Integer.MAX_VALUE; i++) {
sum += i;
}
TIME_SPEND_TOTAL += System.currentTimeMillis() - TIME_SPEND;
System.out.println("No." + (j + 1) + " : " + (System.currentTimeMillis() - TIME_SPEND) + " ms " +sum);
TIME_SPEND = System.currentTimeMillis();
}
System.out.println(TIME_SPEND_TOTAL / TEST_COUNT + " ms");
}

不要错误的认为本条目介绍的内容暗示着”创建对象的代价非常昂贵,我们应该尽可能的避免创建对象”。相反,由于小对象的构造器只做很少量的显式工作,所以,小对象的创建和回收动作是非常廉价的,特别是在现代的JVM实现上更是如此。通过创建附加的对象,提升程序的清晰性,简洁性和功能性,这通常是件好事。

反之,通过维护自己的对象池 (object pool) 来避免创建对象并不是一种好的做法,除非池中的对象是非常重量级的。真正正确使用对象池的典型对象示例就是数据库连接池。建立数据库连接的代价是非常昂贵的,因此重用这些对象非常有意义。而且,数据库的许可可能限制你只能使用一定数量的连接。但是,一般而言,维护自己的对象池必定增加代码的复杂度,同时增加内存占用 (footprint),并且还会损害性能。现代的JVM实现具有高度优化的垃圾回收器,其性能很容易就会超过轻量级对象池的性能。

与本条目对应的是第39条中有关”保护性拷贝(defensive copying)” 的内容。本条目提及”当你应该重用现有对象的时候,请不要创建新的对象”,而第39条中”当你应该创建新对象的时候,请不要重用现有的对象”。注意,在提倡使用保护性拷贝的时候,因重用对象而付出的代价要远远大于因创建重复对象而付出的代价。必要时如果没能实施保护性拷贝,将会导致潜在的错误和安全漏洞;而不必要地创建对象则只会影响程序的风格和功能。

你可能需要编写只包含静态方法和静态域的类(例如一些工具类) 。这些类的名声很不好,因为有些人在面向对象的语言中滥用这样的类来编写过程化的程序。但它们也的确有他们的用处。

这样的工具类 (utility class) 不希望被实例化,实例对它们没有意义。然而,在缺少显式构造器的情况下,编译器会自动提供一个共有的,无参的缺省构造器(default constructor)。对于用户而言,这个构造器与其他的构造器没有任何的区别,在已发行的API中常常可以看到一些被无意识地实例化的类。

企图通过将类做成抽象类来强制该类不可被实例化,这样是行不通的。该类可以被子类化,并且该子类可被实例化。这样甚至会误导用户,以为该类是特意为了继承而设计的。然而,有一些简单的习惯用法可以保证类不被实例化。由于只有当类不包含显式构造器时,编译器才会产生缺省的构造器,因此我们只要让这个类包含私有构造器,它就不能被实例化了:

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

由于显示的构造器是私有的,所以不可以在该类的外部访问它。AssertionError不是必需的,但是它可以避免不小心在该类的内部调用构造器。他保证该类在任何情况下都不会被实例化。这种习惯用法有点违背直觉,好像构造器的声明就是设计成不能被调用一样。因此,较好的做法是,在代码中增加注释(见上文)。

这种习惯用法也有其副作用,它使得一个类不能被子类化。所有的构造器都必须显示或隐式地调用超类 (superclass) 构造器,在这种情况下,子类就没有可访问的超累构造器可以调用了。

Singleton 指仅仅被实例化一次的类。Singleton 通常被用来代表那些本质上唯一的系统组件,比如窗口管理器或者文件系统。(单例模式)

在 Java 1.5 发行版本之前,实现Singleton有两种方法。这两种方法都要把构造器保持为私有的,并指定一个共有的静态成员。

  1. 在第一个方法中,共有静态成员是个 final 域:

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

    私有的构造器仅被调用一次,用来实例化共有的静态 final 域 Elvis.INSTANCE。由于缺少共有的或者受保护的构造器,所以保证了Elvis的全局唯一性:一旦 Elvis 类被实例化,只会存在一个Elvis实例,不多也不少。

    但要提醒一点:享有特权的客户端可以借助AccessibleObject.setAccessible方法,通过反射机制调用私有构造器 (java 反射攻击) 。如果需要抵御这种攻击,可以修改构造器,让他在被要求创建第二个实例的时候抛出异常。

  2. 在第二种方法中,共有的成员是个静态工厂方法:

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

    为了使利用这其中一种方法实现的Singleton类变成可序列化的(Serializable),仅仅在声明中加上”implements Serializable” 是不够的。为了维护并保证Singleton,必须声明所有实例域都是顺时的(transient) 的,并提供一个readResolve方法。否则,每次反序列化一个序列化的实例时,都会创建一个新的实例。

    在如下的例子中会导致”假的Elvis”出现。为了防止这种情况,要在Elvis类中加入下面这个readResovle方法:

    1
    2
    3
    4
    5
    // readResovle 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;
    }
  3. 从 Java 1.5 发行版本起,实现Singleton还有第三种方法。只需要编写一个包含单个元素的枚举类型:(推荐方法)

    1
    2
    3
    4
    5
    // Enum singleton - the preferred approch
    public enum Elvis{
    INSTANCE;
    public void leaveTheBuilding(){ ... }
    }

    这种方法在功能上域共有域方法相近,但是他更加简洁,无偿的提供了序列化机制,绝对防止多次实例化,即使是在面对复杂序列化或者反射攻击的时候。虽然这种方法还没有广泛的采用,但是单元素的枚举类型已经成为实现Singleton的最佳方法。

隐式类型的局部变量是为了支持匿名类型而加入C#语言的。

用 var 来声明变量而不指明其类型,可以令开发者把注意力更多的集中在名称上,从而更好的了解其含义。

在很多情况下,完全可以使用 var 来声明隐式类型的局部变量,因为编译器会自动选择合适的类型。但是不能滥用这种方法,这样可能令代码难以阅读,甚至产生微妙的类型转换 BUG。

局部变量的类型推断机制并不影响 C# 的静态类型检查。类型推断不等于动态类型检查。用 var 来声明的变量是强类型的,他的类型由赋值符号右侧值的类型确定。var 的意义在于,你不用把变量的类型告诉编译器,编译器会替你判断。

如下情况变量类型是显而易见的 (构造函数与工厂方法

1
2
var foo = new MyType();
var thing = AccountFactory.CreateSavingAccount();

如下情况变量类型没有清晰的指出

1
var result = someObject.DoSomeWork(anotherParameter);

在这种情况下应当在变量声明时指定清晰的名称,尽管方法并没有指出返回类型,但这样就能让开发者推断出变量类型,例如:

1
var HighestSellingProduct = someObject.DoSomeWork(anotherParameter);

查看代码的人会根据自己的理解认定该变量类型,可能恰好与变量在运行期的真实类型相符。但编译器不会像人那样思考,而是根据声明判定其在编译期的类型。若用 var 进行声明,编译器会推断其类型,而开发者看不到编译器推断的类型。因此,他们所认定的类型可能不符,这会在代码的维护中导致错误的修改,并产生一些本来可以避免的 Bug。

如果隐式变量的局部类型是 C# 的数值类型,那么还会产生一些另外的问题,因为在使用这些数值的时候可能会进行各种形式的转换。有些转换是宽转换 (widening conversion),这种转换是安全的,例如从 float 到 double,但还有一些转换是窄转换 (narrowing conversion),这种转化会使精度下降,例如从 long 到 int。如果明确的指出数值变量的类型,那么可以更好的控制,并且编辑器也有可能将窄转换标记出来。

总之,除非开发者必须看到变量的声明类型之后才能正确的理解代码的含义,否则优先考虑var来声局部变量(此处的开发者当然也包括编写代码的人本身,因为可能有查看早前写过的代码的需求)。注意上文的优先而不是总是,例如上文的转换错误问题,对int, float, double等数值型的变量,就应该指出明确类型。

C# 有两种常量,一种是**编译期 (compile-time)的,另一种是运行期 (runtime)**的。

1
2
3
4
5
// Compile-time constant:
public const int Millennium = 2000;

// Runtime constant:
public static readonly int ThisYear = 2004;

以上代码展示了如何在 class 或 struct 的范围内声明这两种常量 此外编译期常量还能在方法中声明,而 readyonly 常量则不行。

编译器的常量取值嵌入目标代码中。例如

1
if(myDateTime.Year == Millennium)

编译成 IL 之后,与直接使用字面量2000是一样的

1
if(myDateTime.Year == 2000)

运行期常量与之不同,如果代码中使用到了运行期常量,那么其生成的 IL 的、也会同样引用该变量,而不会直接使用字面量。

这两种变量支持的值也不一样。编译期的常量只能用来表示内置的 int, float, enum, string。非基本变量不能使用编译期常量声明,需要使用 readonly。在生成 IL 的过程中,只有用来表示这些原始类型的编译期常量才会替换成字面量。

无法编译,试图使用 new 操作符进行初始化: (Ryuu: 即使是参数是值类型也不行,其对象在编译期不存在)

1
2
3
// DON'T DO THIS!
// Does not compile ,use readonly instead:
private const DateTime classCreation = new DateTime(2000, 1, 1, 0, 0, 0);

编译期常量只能用数字,字符串或 null 初始化。readonly 常量在执行完构造函数 (constructor) 之后不可以再修改。(和编译期常量不同,他的值是在执行完构造函数后才初始化的)

在生成 IL 的时候,代码中的编译期常量会直接以其常量值写入,如果在制作另外的程序集 (assembly) 的时候用到了该程序集中的编译期常量,那么这个常量将会以字面值写入另外的程序集。

有的时候开发者确实想把某个值固定在编译期,比如程序版本记录,如果更新整个项目,那么里面每个版本号都会变为最新,如果仅更新其中某些程序集,那么只有更新的程序集的版本号会变为最新值。

const 的性能比 readonly 的要好。由于程序集可以直接访问值,而不用查询变量,因此性能稍高。但是,开发者需要考虑是否值得为了这点性能而使得代码变得僵硬。在决定这么做之前,您应该先通过 profile 工具做性能测试。(可以试试 BenchmarkDotNet)

const 关键字用来声明那些必须在编译期得以确定的值,例如 attribute 参数、switch case 语句的标签、enum 的定义等,偶尔用于声明不会随版本而变化的值。除此之外的值考虑用 readonly 常量声明。

静态工厂和构造器有个共同的局限性:都不能很好地扩展大量的可选参数

例:

考虑用一个类表示包装食品外面显示的营养成分标签。这些标签中有几个域是必需的:每份含量,每罐含量,每份卡路里,还有超过20个可选域:总脂肪量,饱和脂肪量,转化脂肪量,胆固醇,钠等等。大多数的产品在某几个可选域中都会有非0的值。

对于这样的类,应该用哪种构造器或者静态方法来编写呢?

1. 重叠构造器 (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
public class NutritionFacts {
private final int servingSize; //(ml) required
private final int servings; //(per container) required
private final int calories; // optional
private final int fat; //(g) optional
private final int sodium; //(mg) optional
private final int carbohydrate; //(g) 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,这个参数本是不用初始化的,就6个参数的情况下,还说的过去,随着参数增加,这样就不行了。

重叠构造器是可行的,但当参数增多时,客户端的代码将变得很难编写,阅读性也较差,并且如果初始化时容易填错顺序,导致运行时的错误。

2. JavaBeans模式

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
// JavaBeans Pattern - allows inconsistency, mandates mutability
public class NutritionFacts {
private int servingSize = -1; //(ml) required
private int servings = -1; //(per container) required
private int calories = 0; // optional
private int fat = 0; //(g) optional
private int sodium = 0; //(mg) optional
private int carbohydrate = 0; //(g) optional

public NutritionFacts() {
}

public void setServingSize(int servingSize) {
this.servingSize = servingSize;
}

public void setServings(int servings) {
this.servings = servings;
}

public void setCalories(int calories) {
this.calories = calories;
}

public void setFat(int fat) {
this.fat = fat;
}

public void setSodium(int sodium) {
this.sodium = sodium;
}

public void setCarbohydrate(int carbohydrate) {
this.carbohydrate = carbohydrate;
}
}

// 实例化
NutritionFacts cocaCola = new NutritionFacts();
cocaCola.setServingSize(240);
cocaCola.setServings(8);
cocaCola.setCalories(100);
cocaCola.setSodium(35);
cocaCola.setCarbohydrate(27);

JavaBeans模式创建实例很容易,代码可读性也很强,但其有很严重的缺点:

  1. 构造的过程包含多个调用,在构造过程中JavaBeans可能处于不一致状态

    例如一个线程正在使用setter初始化值,而另一个线程正用getter取得该对象的字段

  2. 使用JavaBeans模式则该类不可成为不可变类 (因为有setter访问器)

3. Builder模式

此模式有重叠构造器的安全性,也有JavaBeans模式的高可读性

不直接生成需要的对象,而是得到一个builder对象,调用所有的必要的构造器或静态工厂,设置每一个需要设置的参数,最后调用一个无参数的build方法来生成一个不可变对象。

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
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 NutritionFacts(Builder builder) {
servingSize = builder.servingSize;
servings = builder.servings;
calories = builder.calories;
fat = builder.fat;
sodium = builder.sodium;
carbohydrate = builder.carbohydrate;
}

public static class Builder {
// Required parameters
private int servingSize;
private int servings;
private int calories;
private int fat;
private int sodium;
private int carbohydrate;

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

// builder的 setter 返回自身实现链接调用
public Builder calories(int calories) {
this.calories = calories;
return this;
}

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

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

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

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

// 实例化
NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8).calories(100).sodium(35).carbohydrate(27).build();

Builder 模式的可读性提高了很多,代码简易。Builder模式模拟了了具名可选参数

如果该类包含多个字段,Builder模式就是种不错的选择,特别是当大多数参数都是可选的时候。与重叠构造器与JavaBeans相比要更加安全与易读。

静态工厂方法与构造器不同的优点

  1. 它们有名称 (静态方法)

    例:构造器 BigInteger(int, int, Random) 返回的 BigInteger 可能为素数,如果用名为 BigInteger.probablePrime 的静态工厂方法来表示,显然更为清楚 (1.4的发行版本中最终增加了这个方法)

  2. 不必每次调用时都创建一个新对象 (可以将构建好的实例进行缓存并重复利用)

    Boolean.valueOf(boolean) 从来不创建对象。这种方法类似于 Flyweight模式(享元模式)。如果程序经常创建相同的对象,并且创建对象的代价高,这种技术将会极大的提升性能。

  3. 可以返回原返回类型的任何子类型对象 (可以返回隐藏类的实例,将实现类隐藏将使得API十分简洁)

  4. 创建参数化类型(泛型)实例的时候,使代码变得更加简洁 (类型推导 type inference)

    1
    2
    3
    4
    5
    6
    7
    8
    // 调用参数化构造器时,指明类型
    Map<String, List<String>> map = new HashMap<String, List<String>>();
    // 假如在 HashMap 中提供静态工厂
    public static <K, V> HashMap<K, V> newInstance() {
    return new HashMap<K, V>();
    }
    // 创建实例将会变简洁
    Map<String, List<String>> map = HashMap.newInstance();

静态工厂方法的缺点

  1. 类如果不含公有的或者受保护的构造器,就不能被子类化

  2. 与其他的静态方法实际上没有任何区别

    在API文档中,静态工厂方法不会像构造器一样被单独列出并标识,对于仅提供静态工厂方法的类而言,想查到如何实例化该类是比较麻烦的。

    静态工厂方法的一些惯用名称如下:

    1. valueOf
    2. of
    3. getInstance
    4. newInstance
    5. getType
    6. newType

静态工厂方法和共有构造器各有用处,一般情况下静态工厂更加合适,优先提供静态工厂,而不是共有构造器

个人总结: 静态工厂方法是对获取对象的一种封装(封装为静态方法),具体实现可隐藏于其中,具有更高的灵活性

类型参数的约束指出了能完成该泛型类工作的类必须具有的行为。若是某一类型无法满足约束,那么自然无法用于该泛型类型中。不过这也意味着,每次在泛型类型中引入新的的束,都会给该类型的使用者增加更多的工作。实际情况各不相同,因此并没有万能的解决方案,不过太过极端总归是不好的。若是不给出任何约束,那么则必须在运行时进行过多检查,比如使用强制转换。反射并抛出运行时异常等来保证程序的正确性。而若是约束过多,那么也会让类的使用者觉得麻烦。因此你要找到那个恰到好处的中间点,精确地时类型参数给出约束,不多也不少。
约束能让编译器了解某个类型参数更具体的信息,而不仅限于极为笼统的System.Object。在创建泛型类型时,C# 编译器将要为泛型类型的定义生成合法的IL。而在进行编译时,虽然编译器对今后可能用来替换类型参数的具体类型了解甚少,但你需要生成合法的程序集。若是不添加任何约束,那么编译器只能假设这些类型仅具有最基本的特性,即System.Object中定义的方法。
​ 编译器无法猜测出你对类型的假设,唯一能够确认的就是该类型继承于System.Object(因此我们无法创建不安全的泛型,也无法将指针作为类型参数。)我们知道。System.Object的功能非常有限,因此若是使用到了任何非System.Object的功能,编译器均会抛出异常。你甚至都无法使用最基础的构造函数new T(),因为若某个类型仅提供了有参数的构造函数,那么该构造函数将会被隐藏。

最小化约束方法有很多种.其中最常见的一种是,确保泛型类型不要求其不需要的功能

以IEquatable为例,这个是个很常用的接口,且创建新类型时也经常用到.

我们可以重写AreEqual方法,让其调用Equals方法.

1
2
3
4
public static AreEqual<T>(T left, T right)
{
return left.Equals(right);
}

在上述代码中,若AreEqual()定义在一个带有IEquatable约束的泛型类中,那么AreEqual将调用IEquatable.Equals.否则, C#编译器则不会假设具体类型一定会实现IEquatable,因此唯一可用的Equals()就是System.Object.Equals().

上述示例可以看到C#泛型和C++模板之间的主要区别.

在C#中,编译器仅能使用约束给出的信息来生成IL. 即使为某个特定的实例指定的类型拥有更好的方法,也不会在运行时使用,除非在该泛型类型编译时就给出限定.

.NET 平台的头两个版本 (1.1 及 1.2) 不支持泛型

System.Object 是所有类型的最终基类

为何要使用泛型代码?

  1. 健壮性

将 Object 作为 参数或返回类型 难免会出现意外的类型 导致运行时的错误

  1. 性能

    1.1 版本的弱类型系统需要在代码中添加检验代码以保证参数或返回类型的正确

    ,当检验失败时还会执行更多的其他代码,这不可避免的导致了更大的性能开销

“总体说来,弱类型系统将带来各种各样的麻烦,从性能低下直至程序异常终止等。”

自 .NET 2.0 引入了泛型

以 System.IComparable 为例比较泛型版本的优势

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public interface IComparable<T>
{
int CompareTo(T other);
}

// 1.1版本
public interface IComparable
{
int CompareTo(object obj);
}

// 实现
public int CompareTo(Customer right)
{
return Name.CompareTo(right.Name);
}

public int CompareTo(object right)
{
if (!(right is Customer))
throw new ArgumentException("Argument not a customer","right");
Customer rightCustomer = (Customer)right;
return Name.CompareTo(rightCustomer.Name);
}

使用泛型接口的四个优势

  1. 简洁
  2. 高效
  3. 避免了所有的装箱/拆箱以及类型转换操作 (个人认为这点其实包含在1与2中)
  4. 不会抛出异常 (非泛型版本中运行时可能出现的异常变为可由编辑器捕获的异常

个人总结: 用基类传来传去固然是不怎么安全,并且还会增加代码量与更多性能开销,一般情况下用泛型约束确实是最好的选择 (参数不继承自同一基类另说)

0%