aoi学院

Aisaka's Blog, School of Aoi, Aisaka University

设计模式-20软工第7周安排-结构型模式-外观模式、享元模式

师说

从字面上如何理解:享: 共享,分享;元: 元素,共同部分,属性;模式: 一种方法。也就是说,享元模式是一种将共同部分共享起来的方法。一个类它可能生成好多对象,但这些对象根据属性值的不同一共分成N类,每种类型中属性值都是一样的。在这种情况下,如果创建好多对象,那么这些对象中很多属性值都是重复的,从而造成了大量的内存浪费。而享元模式能够解决重复对象的内存浪费的问题。

何时使用?享元模式可以避免大量非常相似的对象的开销。好像我们平时开发文本编辑器,在文本文档中可以插入图片、动画、视频等多媒体资料,为了节约系统资源,相同的图片、动画和视频在同一个文档中只需保存一份,但是可以多次重复出现,而且它们每次出现时位置和大小均可不同。这就可以用享元模式。在程序设计中,如果发现需要大量细粒度的类对象来表示数据,而且这些类除了几个参数不同以外,其他的属性都是相同的,这时候就可以使用享元模式。类中相同的属性可以通过工厂类来共享,这些属性就是享元类的内部状态;而那些会变化的属性放在新建的外部对象中,作为参数传递给享元类的函数。

享元模式的优点:享元模式通过共享对象的方式,把所有对象的公共属性存放在同一个对象中,不同的属性存放在外部类中,从而起到了节省存储空间的作用。


也可以看我大三以前的上这门课时的博客:

设计模式-结构型模式-结构型模式概述
设计模式-结构型模式-外观模式
设计模式-结构型模式-享元模式


享元模式(Flyweight)

什么是享元模式

享元模式是构造型设计模式之一,它通过与其他类似对象共享数据来减少内存使用。

享元模式(Flyweight)中有一个类似Factory模式中的对象构造工厂FlyWeightFactory,当客户端程序员(client)需要一个对象的时候就会向FlyWeightFactory发出请求对象的消息getFlyweight()消息,FlyweightFactory拥有一个管理、存储对象的“仓库”(或者叫对象池,vector等实现),getFlyWeight()消息会遍历对象池中的对象,如果已经存在则直接返回给client,否则创建一个新的对象返回给client。当然可能也有不想被共享的对象。

  • 抽象享元角色:所有具体享元类的父类,规定一些需要实现的公共接口。
  • 具体享元角色:抽象享元角色的具体实现类,并实现了抽象享元角色规定的方法。
  • 享元工厂角色:负责创建和管理享元角色。

使用场景:是以共享的方式,高效的支持大量的细粒度的对象。


示例代码

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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
#include <iostream>
#include <map>
#include <iterator>

using namespace std;

/**
* @brief The Person class
* 享元对象
*/
class Person
{
public:
Person(string name,int age) {
m_name = name;
m_age = age;
}

virtual ~Person() {}
virtual void printType()=0;

protected:
string m_name;
int m_age;
};


/**
* @brief The Teacher class
* 享元细粒度对象
*/
class Teacher : public Person
{
public:
Teacher(string id,string name,int age) : Person(name, age) {
m_id = id;
}

void printType() {
cout<<"name:"<<m_name<<" age:"<<m_age<<" id:"<<m_id<<endl;
}

private:
string m_id;

};

/**
* @brief The FlyWeightFactory class
* 享元工厂,在这个案例中我们通过享元工厂要获得
* 的享元细粒度对象是Teacher
*/
class FlyWeightFactory
{
public:
FlyWeightFactory() {
m_map.clear();
}

~FlyWeightFactory() {
while(!m_map.empty()) {
Person* tmp = NULL;
map<string , Person*>::iterator it = m_map.begin();

tmp = it->second;
m_map.erase(it); //把结点从map容器中删除
delete tmp;
}
}

Person* getTeacher(string id) {
Person* tmp = NULL;
map<string ,Person*>::iterator it;
it = m_map.find(id);
if(it == m_map.end()) { //没有找到相同id的老师
string name;
int age;
cout<<"请输入老师的名字:"<<endl;
cin>>name;
cout<<"请输入老师的年龄:"<<endl;
cin>>age;
tmp = new Teacher(id,name,age);
m_map.insert(pair<string,Person*>(id,tmp));

} else {
tmp = it->second;
}

return tmp;
}

private:
map<string ,Person*> m_map;
};

int main(int /*argc*/, char */*argv*/[])
{

Person* p1 = NULL;
Person* p2 = NULL;
FlyWeightFactory* fwf = new FlyWeightFactory;
p1 = fwf->getTeacher("001");
p1->printType();

p2 = fwf->getTeacher("001");
p2->printType();


delete fwf;
fwf = NULL;

return 0;
}

运行结果:


浅谈迪米特法则

前面我们谈到了几种类与类之间的关系,现在我们来深入一下对象与对象之间的通信问题。

为什么要深入对象与对象之间的通信呢,其根本在于,系统中不会存在唯一的对象,不同的对象势必要相互进行交流。

初学者的问题

在我们刚开始学习编程的时候,通常会将所有的方法都声明为公有(public),但随着我们代码量的增加,我们都会遇到一个典型的问题:

  • 在调用某个对象的方法时,我们发现编译器提示这个对象所有的方法,这意味着该对象处在不安全的状态。为什么这么说呢?如果我们将这个对象比作一个人,那么这个人在别人面前是赤裸的,没有任何隐私,这让别人有机会观察你的一切行为,并某刻致命一击。除此之外,这个完全暴露的人,也会让别人不知所措。

这显然不是我们想要的,因此我们需要某种机制来限制的对象信息的公开:哪些信息是可以公开的,哪些是不可以公开的,在java中,我们通过方法的权限来实现这一点,比如private修饰的方法只有对象自己内部可以调用,public修饰的方法是公开给其他对象的等。

现在,你可能已经明白,java的设计者为什么要“多此一举”的为方法设计权限了。那么有人会问,我该怎么确定哪个方法应该被设计成公有的,哪些又应该被设计成私有的呢?

当你心里有这个疑问的时候,说明你已经开始关注我们经常提到的面向对象编程的原则之一:封装,即如何划分对象的结构。

我们都知道对象的结构的可被划分为静态属性和动态属性,所谓的静态属性就是值对象固有的属性,比如任何一个生命体都有年龄,而动态属性也称为行为属性,指的是对象所表现出来的行为,比如袋鼠能跳,能呼吸等。而这静态属性和动态属性又可以细分为可公开的静态属性,可公开的动态属性等。也就是说,划分对象的结构实则就是确定某个对象的动态属性和静态属性,在此基础上再来确定属性是否可公开等。

不难发现,这个过程和我们的认知的思维过程很类似:大脑试图从各种各样的的物体中抽取特征。比如,我们看到猫,狗,仙人掌,为了能区分它们,我们的大脑会对这三者进行特征抽取,比如猫和狗都可以移动,有眼睛,会叫,有爪子,而仙人掌则是不可移动,有刺,不能叫等,通过这种特种抽取,我们能区分出动物和植物的区别。换言之,我们之所以能区分出不同的物体,都是因为我们的大脑已经默默的为我们做了特征抽取的工作,这个过程如果由我们主动去做就称之为抽象编程。


揭秘迪米特法则

迪米特法则(Law of demeter, 缩写是LOD)要求:一个对象应该对其他对象保持最少了解,通缩的讲就是一个类对自己依赖的类知道的越少越好,也就是对于被依赖的类,向外公开的方法应该尽可能的少。

迪米特法则还有一种解释:Only talk to your immediate friends,即只与直接朋友通信.首先来解释编程中的朋友:两个对象之间的耦合关系称之为朋友,通常有依赖,关联,聚合和组成等.而直接朋友则通常表现为关联,聚合和组成关系,即两个对象之间联系更为紧密,通常以成员变量,方法的参数和返回值的形式出现.

那么为什么说是要与直接朋友通信呢?观察直接朋友出现的地方,我们发现在直接朋友出现的地方,大部分情况下可以接口或者父类来代替,可以增加灵活性.
(需要注意,在考虑这个问题的时候,我们只考虑新增的类,而忽视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
41
42
43
44
45
46
47
//计算机类
public class Computer{

public void saveCurrentTask(){
//do something
}
public void closeService(){
//do something
}
public void closeScreen(){
//do something
}

public void closePower(){
//do something
}

public void close(){
saveCurrentTask();
closeService();
closeScreen();
closePower();
}
}

//人
public class Person{
private Computer c;

...

public void clickCloseButton(){
//现在你要开始关闭计算机了,正常来说你只需要调用close()方法即可,
//但是你发现Computer所有的方法都是公开的,该怎么关闭呢?于是你写下了以下关闭的流程:
c.saveCurrentTask();
c.closePower();
c.close();

//亦或是以下的操作
c.closePower();

//还可能是以下的操作
c.close();
c.closePower();
}

}

发现上面的代码中的问题了没?

我们观察clickCloseButton()方法,我们发现这个方法无法编写:c是一个完全暴露的对象,其方法是完全公开的,那么对于Person来说,当他想要执行关闭的时候,却发现不知道该怎么操作:该调用什么方法?靠运气猜么?如果Person的对象是个不按常理出牌的,那这个Computer的对象岂不是要被搞坏么?


迪米特法则第一要义

现在我们来看看迪米特法则的第一点:从被依赖者的角度,只应该暴露应该暴露的方法。那么这里的c对象应该哪些方法应该是被暴露的呢?很显然,对于Person来说,只需要关注计算机的关闭操作,而不关心计算机会如何处理这个关闭操作,因此只需要暴露close()方法即可。
那么上述的代码应该被修改为:

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 Computer{

private void saveCurrentTask(){
//do something
}
private void closeService(){
//do something
}
private void closeScreen(){
//do something
}

private void closePower(){
//do something
}

public void close(){
saveCurrentTask();
closeService();
closeScreen();
closePower();
}
}

//人
public class Person{
private Computer c;
...

public void clickCloseButton(){
c.close();
}

}

看一下它的类图:

接下来,我们继续来看迪米特法则的第二层含义:从依赖者的角度来说,只依赖应该依赖的对象。

这句话令人有点困惑,什么叫做应该依赖的对象呢?我们还是用上面“关闭计算机”的例子来说明:

准确的说,计算机包括操作系统和相关硬件,我们可以将其划分为System对象和Container对象。当我们关闭计算机的时候,本质上是向操作系统发出了关机指令,而实则我们只是按了一下关机按钮,也就是我们并没有依赖System的对象,而是依赖了Container。这里Container就是我们上面所说的直接朋友—只和直接朋友通信

我们就这点,继续深入讨论一下:
only talk to your immedate friends
这句话只说明了要和直接朋友通信,但是我觉得这还不完整,我更愿意将其补充为:
make sure your friends, only talk to your immedate friends, don’t speak to strangers.

大意是:**确定你真正的朋友,并只和他们通信,并且不要和陌生人讲话.**这样做有个很大的好处就是,能够简化对象与对象之间的通信,进而减轻依赖,提供更高的灵活性,当然也可以提供一定的安全性.

  • 现在来想想现实世界中的这么一种情况:你是一个令人瞩目的公众人物,周围的每个人都知道你的名字,当你独自走在大街上的时候会是怎么样的一种场景?每个人都想要和你聊天!,和你交换信息!!接着,你发现自己已经寸步难行了.如果这时候你有一个经纪人,来帮你应对周围的人,而你就只和这个经纪人通信,这样就大大减轻了你的压力,不是么?此时,这个经济人就相当于你的直接朋友.

迪米特法则第二要义

现在,我们再回顾”关机计算机”这个操作,前面的代码只是遵从了”暴漏应该暴漏的方法”这一点,现在我们结合第二点来进行改进:System和Container相比,System并非Person的直接朋友,而Container才是(Person直接打交道的是Container).因此我们需要将原有的Computer拆分成System和Cotainer,然后使Person只与Container通信,因此代码修改为:

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
//操作系统
public class System{

private void saveCurrentTask(){
//do something
}
private void closeService(){
//do something
}
private void closeScreen(){
//do something
}

private void closePower(){
//do something
}

public void close(){
saveCurrentTask();
closeService();
closeScreen();
closePower();
}
}

//硬件设备容器
public class Container{
private System mSystem;

public void sendCloseCommand(){
mSystem.close();
}
}

//人
public class Person{
private Container c;
....

public void clickCloseButton(){
c.sendCloseCommand();
}

}

来看一下它的类图:


重构,改善既有设计

在上文中,我们还提到,直接朋友出现的地方,我们可以采用其接口或者父类来代替.那么在这里,我们就可以为Container和System提供相应的接口

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
//System interface
public interface ISystem{
void close();
}

//System
public class System implements ISystem{

private void saveCurrentTask(){
//do something
}

private void closeService(){
//do something
}

private void closeScreen(){
//do something
}

private void closePower(){
//do something
}

@override
public void close(){
saveCurrentTask();
closeService();
closeScreen();
closePower();
}
}

//IContainer interface
public interface IContainer{
void sendCloseCommand();
}

//Contanier
public class Container implements IContainer{
private System mSystem;

@override
public void sendCloseCommand(){
mSystem.close();
}
}

//Person
ublic class Person{
private IContainer c;
....

public void clickCloseButton(){
c.sendCloseCommand();
}

}

来看一下它的类图:

对比这两种方案,明显这种方案二的解耦程度更高,灵活大大增强.不难发现,这应用了我们前面提到的依赖倒置,即面向接口编程.

除此之外,我们发现随着不断的改进,类的数量也在不断的增加,从2个增加到5个,这意味着为了解耦和提高灵活性通常要编写的类的数量会翻倍.因此,你需要在这做一个权衡,切莫刻意为了追求设计,而导致整个系统非常的冗余,最终可能得不偿失.


总结

有人会觉得Container像是一个中介(代理).没错,我们确实可以称其为中介,但这并不能否认他是我们的直接朋友:在很多情况下,中介可以说是我们的一种代表,因此将其定义为直接朋友是没有任何问题的.比如,当你想要租房的时候,你可以找房屋中介,对方会按照你的标准为你寻找合适的住房.但是问题来了:那么做一件事情需要多少中介呢?总不能是我委托一个中介A帮我找房子,但中介A又委托了中介B,中介B又委托了中介C….等等,如果真的是这样,那还不如我自己去找房子效率更高.在实际开发中,委托的层次要控制在6层以下,多余6层以上的会使得系统过分的冗余和并切会委托层次过多而导致开发人员无法正确的理解流程,产生风险的可能会大大提高.


参考资料

20软工第7周安排
漫谈设计模式之享元模式(Flyweight)
​白话设计——浅谈迪米特法则