getter和setter的那些事

相信每一个以Java或者C++作为编程入门语言的程序员,一定会记得一条金科玉律:字段(Filed)要声明成private,如果要读取或修改字段,就声明一些公开方法(Public Method),以get和set开头,像这段Java代码一样:

1
2
3
4
5
6
7
8
9
10
11
public class Person {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

这些以get和set开头的方法,被称为getter和setter。时间久了,这种做法似乎成了一种神圣的约定,每个人都记得应该这么写,而忘记了为什么这么写。尤其是,当IDE变得足够智能,getter和setter可以自动生成,想要挑战这个约定的人就更少了——不过多按两下快捷键而已。

但是,当你写了很多程序,写过很多getter和setter,尤其是有些类方法,只有getter和setter时,总会有一天,你会疑惑,我到底为什么要这么干?

Why private field?

要解释为什么需要getter和setter,先要知道为什么字段应该是private的。

在汇编语言时,数据都是公开的。所谓公开,是指几乎任何指令,都可以作用在任意的数据块上。编写某段代码的程序员,通常知道自己在做什么,自己正在操作的数据代表什么含义,也就能选择合适的指令。而这段代码的用户——例如另一个程序员——可能并不知道数据的确切含义,比如把一个本应代表字符的数据块,当成数字进行计算,导致得到的结果和预期不符。

后来,类型的概念出现,某些操作开始只能作用在某些特定的类型上。以C语言为例,“*”这样的操作只能作用在数值类型上;而strcat函数则只能作用在char*类型上。这时,数据,和作用在数据上的函数,是分开的两部分,尽管两者之前保持着千丝万缕的关系。而一个函数,能够作用在哪些数据上,仅仅通过类型来限制,很难满足真实业务需求。比如,一个代表年龄的数值型变量,可能会被错误的传递给处理温度(也是数值型)的函数,得到一个负值作为返回值。

既然数据和函数是相关联的,何不将两者放在一起呢?于是在基本类型之上,更进一步的抽象被提出来,即数据,应该和相关的操作封装在一起。这就是对象(Object)的概念。一个对象,应该由该对象代表的数据,以及可以作用于这些数据的操作组合而成。

理想情况下,数据应该和所有相关的操作封装在一起,也就是说,除了这些操作外,不能有其他操作作用于这些数据。因此,数据需要被保护起来。这就是为什么Java, C++, C#等面向对象语言提供了private, protected, public等accessor来控制对数据和方法的访问权限。

另一方面,当前的编程语言,本质上都是图灵机的一种实现。每一个独立的代码单元,都可以看成一个作用在无限长纸带上的机器,这个机器存储着自己的内部状态,每次操作可以从纸带上的一个格子读取数据,然后计算一个结果输出到纸带上,同时更新自己的状态。这个机器的内部状态转移,对于计算结果的正确性,有着至关重要的作用。因此,要保证机器处于合法的状态,就必须保护内部状态,只在某些可控的操作下更新。

Why getter & setter?

数据需要被保护起来,而getter和setter是将数据暴露出来。看起来这是一对矛盾。

前面提到,每一个独立的代码单元都可以看成是一个图灵机。而要完成一个复杂任务,需要多个代码单元相互合作,组成更强大的图灵机。多个代码单元之间要合作,就不可避免的需要知道互相的状态,甚至一个代码单元需要修改另一个代码单元的状态。

也就是说,为了合作的需要,代码单元需要将数据暴露出来。

那么直接将数据字段设置为public,与通过getter和setter方式来暴露字段,有什么区别?

面向对象编程中有一条非常重要的原则,就是面向接口(Interface)编程。只要在一个稍具规模的团队工作过,就一定经历过与不同人写的代码进行集成的痛苦。不论设计阶段做的多么详尽,在开发过程中,接口都不可避免的会发生变化。一旦接口变化,所有与它相关的代码都要修改。所以,面向对象编程提出,尽量保证接口稳定,而内部逻辑可以改变,以达到最小化变化的目的。

1
2
3
public class Person {
public String name;
}

如果直接将内部数据字段暴露出来,比如上面这段代码中的name,如果某天有一个新的需求,要求所有名字都用大写字母表示,就只能添加一个新的接口upperName,而使用name的地方,需要修改调用方式。如果采用文章开始时的代码,即添加getter和setter,有新需求出现时,只需修改getName方法,不需要修改调用处的代码,即可实现。

正是考虑到未来可能出现的功能扩展,所以像Java和C++这样的语言,即使还不确定是否应该将字段保护起来,也要写getter和setter,而这也导致了很多多余代码。

Why getter & setter, again?

然而,却并不是所有语言都是这样的。比如和Java最像的C#,虽然也建议将字段设置为private,但是却可以不用getter和setter。

1
2
3
4
5
6
7
8
class Person {
private string _name;
public string name {
get { return _name; }
set { _name = value; }
}
}

上面这种property的写法,让Person的调用代码可以很直接的访问私有变量。

1
2
3
4
5
6
7
8
9
10
11
class Program
{
static void Main()
{
Person p = new Person()
p.name = "Steve"
System.Console.WriteLine("Hello, " + p.name);
}
}

另一个提供property特性的语言是Python.

1
2
3
4
5
6
7
8
9
10
11
class Person(object):
def __init__(self, name):
self._name = name
@property
def name(self):
return self._name
@name.setter
def name(self, value):
self._name = value

因为在C#和Python中,property的访问方式和直接将数据字段暴露出来的访问方式完全一样,所以在写代码时可以考虑先将数据暴露出来,避免过多的getter和setter,减少冗余代码。

One more thing...

Java代码的冗余是出了名的,同样的功能,像Python,甚至C#,可以写出更简洁,可读性更好的代码。不过,想要实现类似property的功能,也不是不可能。lombok提供了很多方便的注解来帮助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
31
32
33
34
35
36
37
38
39
40
public class Person {
private String id;
private String name;
private String identity;
private Logger log = Logger.getLogger(Person.class);
public Person() {
}
public Person(String id, String name, String identity) {
this.id = id;
this.name = name;
this.identity = identity;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getIdentity() {
return identity;
}
public void setIdentity(String identity) {
this.identity = identity;
}
}

使用lombok,等价于下面这段代码:

1
2
3
4
5
6
7
8
9
@Data
@Log4j
@NoArgsConstructor
@AllArgsConstructor
public class Person {
private String id;
private String name;
private String identity;
}

看起来还不错。不过,因为这只是通过注解做的一种Hack,加了@Data注解,相当于编译器自动生成getter和setter,所以调用代码还是要用getIdgetName这样的方法名来访问变量。