读书笔记-设计模式-可复用版-5种创建型总结

发表于2019-03-28
评论5 5.9k浏览

想免费获取内部独家PPT资料库?观看行业大牛直播?点击加入腾讯游戏学院游戏程序行业精英群

711501594

五种设计创建型设计模式:


 

1.原型设计模式 Prototype

2.单例设计模式 Singleton

3.工厂方法设计模式 Factory Method

4.抽象工厂设计模式 Abstract Factory

5.建造者设计模式 Builder


 

单例模式和原型模式都是创建自身的对象

而工厂方法,抽象工厂,建造者都是创建的第三方对象


 

以前面试的时候,就经历过提问,什么是工厂方法和抽象工厂,以及抽象工厂和建造者模式有什么区别。


 

在使用过程中,要注意,没有哪种设计模式是完美无缺的,都会有两面性,我们能做的就是通过设计模式的应用,提高复用性,扩展性,灵活性,将耦合性降到最低,一点不耦合是不可能的:)

尤其是在面对复杂的需求时.


 

当我们开始实现需求时,一定要记得,能用组合,优先使用组合,面向接口编程,而不要面向具体的类。


 


 

概念定义及说明:

================================


 

1.原型设计模式-Prototype


 

定义:

通过克隆(Clone)原型来创建新的实例

原型是指我们要克隆/复制的对象,已经在内存中,通常是完成了初始化的对象


 

作用:


 

通过克隆原型来创建新的实例,提高了代码的复用性,不需要重新去加载资源,一系列可能比较耗时的初始化工作,直接克隆/复制成果,并对不同的地方再进行调整,提高开发以及代码的运行效率


 

比如,游戏中出现的很多敌人,数值属性状态存在很多的相似性,我们可以先创建好一个敌人原型(模板),通过克隆模板来创建多个敌人


 

Unity中的Prefab就是原型的一个很好应用,将“组装”好的对象,序列化成字节流,以文件的形式存放在磁盘上,使用时,通过读取文件进行反序列化,将字节流还原成对象,具体的参数属性在Prefab中通常是已经计算好的,在反序列化以后再对不同的参数进行修改调整,并且在游戏中,我们也经常通过克隆已经“配置”好的对象,来创建新的对象


 

原型模式可以说已经深入到语言及标准设计层面了,就像迭代器模式已经是语言的一部分了,所以我们在使用过程中,可能会感受不到他们的存在感,实际上,他们一直在起着很重要的作用。


 

注意事项:

浅拷贝(Shadow Copy)深拷贝(DeepCopy)


 

克隆原型一定会面临的两个问题,产生的原因在于值类型和引用类型在内存上存储的区别


 

java和C#均是基于“环境”的语言(虚拟机和CLR),在C/C++等底层语言的基础上,将很多工作进行了简化,克隆方法Clone,在Java和C#中,均定义成了系统级的接口,用户无需自己定义


 

MemeberwiseClone是Object类中提供的用于“浅拷贝”的API,逐成员的复制,如果要克隆的原型中包含其它引用类型,则需要使用深拷贝(DeepCopy),避免引用指向堆中同一块内存地址,另外,引用类型比较多或是循环引用,建议通过序列化和反序列化的形式解决,在实际情况 ,复杂的对象,手动的进行Clone操作是不现实的


 

关于序列化和反序列化,如果不能够替代,还是要利用他的特性,当然,任何耗时的操作,都不能放在Update中按帧执行


 

================================

2.单例设计模式-Singleton


 

概念:

保证当前类的实例,在整个程序的运行周期中,有且仅有一个实例,并提供一个访问它的全局接口。


 

单例最简单,但应用也最为广泛。


 

只要类的对象(职责)是独一无二的,均可以采用Singleton模式


 

需要注意的地方:

1.实现Singleton单例模式,需要注意什么?


 

1) 保证当前的实例,在内存中,有且仅有一个,不可以直接进行new创建对象,构造函数是私有的,提供一个静态的公共接口用于访问唯一的实例


 

2) 不可以被继承,这样会导致实例不唯一,所以类通常都是设置为sealed


 

3)多线程环境下,需要处理同步问题


 

2.实现多线程下,Singleton线程安全有几种有哪些?


 

常见的形式有:

1)single check lock

2)double check lock

3)not quite as lazy

4)full lazy

5)System.Lazy<T>(.Net 4.0(or higher)


 

3.多线程环境(并发)下,数据不同步是如何造成的,如何解决?


 

为了CPU和编译的利用率,提高性能,CPU和编译器会对指令进行重新排序(指令重排reorder),这会引起代码的编写顺序和实际内存的读写顺序是乱序的,单线程环境下是没有问题的,但在多线程环境就会引起数据不同步,导致结果不正确


 

比如你在编写代码的时候,先修改A,再修改B,但内存处理可能并不是按照这个顺序的,可能会调换位置,并且修改的值可能一直保存在了寄存器中,没有更新到缓存或是主存,这样其它线程读取的时候,并不能保证每次读取到的都是新值!


 

解决办法是通过添加内存屏障Memory Barrier


 

Memory Barrier就是一条CPU指令,他可以确保操作执行的顺序和数据的可见性

1)保证执行顺序

2)保证数据的可见性


 

这两点就可以解决多线程的同步问题,确认了执行顺序,值被修改以后也会立即的更新到内存中,保证了下一个线程读取到的值是新的。


 

相当于告诉 CPU 和编译器先于这个命令的必须”先“执行,后于这个命令的必须”后“执行。

内存屏障也会强制更新一次不同CPU的缓存,会将屏障前的写入操作,刷到到缓存中,这样试图读取该数据的线程,会得到新值。确保了同步。


 

C#中,Memory Barrier的API:


 

Thread.MemoryBarrier();


 

3.什么是lock和deadlock,如何模拟一个deadlock?


 

lock语句块用于解决多线程环境下,数据不同步的问题,保证当前只会有一个线程进入到lock语句块中,其它执行线程只能等待lock释放,实际上lock(){ }语句块,隐式的执行了Thread.MemoryBarrier();


 

deadlock是死锁,这是在使用lock语句块时要特别注意的问题,具体lock(xx)所以获取对象锁,有具体的使用规则,可以见详情的解释


 

模拟deadlock:


 

Thread thread = new Thread(new ThreadStart(DeadLock1));

thread.Name = "thread_1";

Thread thread1 = new Thread(new ThreadStart(DeadLock2));

thread1.Name = "thread_2";


 

thread.Start();

thread1.Start();


 

public void DeadLock1()

  {

    lock ("A")

    {

      Debug.Log(Thread.CurrentThread.Name + " get lock A");

      lock ("B")

      {

        Debug.Log(Thread.CurrentThread.Name + " get lock B");

       }

      Debug.Log(Thread.CurrentThread.Name + " release lock B");

     }

    Debug.Log(Thread.CurrentThread.Name + " release lock A");

   }


 

public void DeadLock2()

  {

    lock ("B")

    {

      Debug.Log(Thread.CurrentThread.Name + " get lock B");

      lock ("A")

      {

        Debug.Log(Thread.CurrentThread.Name + " get lock A");

       }

      Debug.Log(Thread.CurrentThread.Name + " release lock A");

     }

    Debug.Log(Thread.CurrentThread.Name + " release lock B");

   }


 

创建两个线程,分别执行DeadLock1,DeadLock2两个方法,运行结果是:

thread_1 get lock A

thread_2 get lock B


 

产生死锁!


 

解释下死锁的产生:

假设线程一先执行,线程一执行了DeadLock1,进入方法内部,lock("A")//获取引用对象“A"的锁,这时候,另外一个线程也执行了DeadLock2,lock("B")//获取引用对象“B"的锁


 

A和B均被锁住(locking)


 

假设A先继续向下执行,执行到lock("B"),但此时B被线程二锁住,线程一处于等待,

线程二继续执行,执行到lock("A"),但A被线程一锁住,尴尬情况就出现了,线程一在等待

线程二释放B,而线程二在等待线程一释放A,就这么僵持,死锁!


 

打麻将的时候,也是会出现死锁的情况,我胡三条,他胡八万,我这里有3个八万,不可能拆,他家里有三个儿三条,也不可能拆,这也是一种死锁


 

5.lazy和greedy的区别


 

这是指类构造器的初始化


 

lazy initilizer,可以翻译为延时初始化,或是懒汉初始化,相应的也会有lazy load


 

lazy的意思是:只有在我调用的时候,我才去初始化它!

不调用的时候,他就一直处于未初始化的状态。


 

Greedy饿汉式,只要我引用类中任意一个静态成员,调用之前,静态字段就会分配内存,占用内存。lazy是只有使用到我的时候,我才会创建分配。


 

6.beforeFieldInit是什么?


 

beforeFieldInit可以对问题5有更明显的解释


 

这是一个.Net中关于类型构造器执行时机的问题,有两种方案:

beforefiledinit(默认)

precise

这两个模式的切换只需要添加一个static构造函数即可,存在静态构造函数则是precise方案,

没有static构造函数则是beforefieldinit方案


 

7.single-lock check和double-lock check的区别是什么,为什么要double-check?


 

single check lock是比较常见的多线程环境下线程安全的Singleton模式,double-check lock只是single check lock的一种优化版本,避免每次获取实例都要lock.


 

double-check lock(DCL)问题,在java下是无法执行的,需要使用voliate,也是因为指令重排导致,但C#也有指令重排,但可以正常的运行,这里我没有去深入研究,先记得在Java中使用时,要注意DCL问题


 


 

其实使用中的注意事项:

1)单例模式的实现,建议使用泛型,避免创建重复的代码

2)具体选择哪种方案,not quite as lazy就可以了,但实际的使用中,每一种

都是可以的,性能差别微乎期微,因为你不可能将他们放在update中按帧执行,如果是这样,为什么不cached呢


 


 

================================

3.工厂方法设计模式 Factory Method


 

概念:


 

定义一个用于创建对象的接口,让子类决定,实例化哪一个类。Factory Method 使一个类的实例化,延迟到子类。


 

讲解工厂方法,必须要提到,参数化的工厂方法(有些资料叫简单工厂)


 

参数化的工厂方法是通过定义一个单独的工厂类,提供一个统一的方法,通过参数,可以是string,enum....通过switch case / if else 返回不同的对象实例。


 

缺点是耦合性高,每次新增或是修改新的对象都要对工厂类进行修改。(有些复杂的需求也是需要用到的,但可以进行优化,比如使用哈希表,配置表,尽量不要使用字符串(没有安全检查)等等)


 

通常采用面向接口的工厂方法,将对象的实例化,放在不同的工厂子类中实现,使用时,面向接口编程,修改和新增都不会影响到其它对象


 

================================

4.抽象工厂设计模式 Abstract Factory


 

概念:


 

提供一个创建”一系列“相关或相互依赖对象的接口。而无需指定它们具体的类。


 

工厂方法是提供一个创建对象(一个)的接口,而抽象工厂则是提供的是一系列产品创建的接口


 

可以说,抽象工厂是工厂方法的集合,这些工厂方法所创建的对象之间是相关的,通常是一个产品的完整系列


 

比如不同的UI显示风格,室内的装修风格,样式style等等


 

一个抽象工厂创建了一个产品的完整系列,如果我们需要改变风格,只需要替换

具体的抽象工厂派生类,这样整个系列的产品都会被改变,系列中的相关的每一部分,都定义在抽象工厂类中。


 

Abstract Factory抽象工厂在实现中一些说明:


 

1.一般每个系列的产品只需要有一个ConcreteFactory,对于这种独一无二的对象,我们可以设置他为Singleton单例


 

2.抽象工厂中,只声明一系列创建产品的接口,具体的创建是由ConcreteProduct子类实现的,这里注是FactoryMehod 工厂方法的应用。


 

3.抽象工厂中,不会指明具体的类,你看不到ConcreteProduct,有的只是抽象或是基类Product,具体由抽象工厂的派生类指定哪种产品!


 

在之前文章的例子中,有提到开一家咖啡馆,要决定装修的风格,风格决定了内部的很多布局,设施,装潢的改变,通过定义抽象工厂(里面包含了创建这些对象的工厂方法),由不同风格的派生类实现,改变风格只需要替换具体的子类即可!


 

如果我现在需要将风格的实现配置化,这种情况可以将风格存放在一个哈希表中,通过字符串可以快速的读取,避免的使用反射,这种形式类似于参数化的工厂方法


 

当需求变得多变复杂的时候,每种设计模式的缺点都会暴露出来,所以要合理的去使用它们。


 

================================

5.建造者设计模式 Builder


 

通常是用于”构建“复杂的对象,并强制一步一步构建的过程,来生成复杂的对象。


 

概念:


 

将一个复杂的对象构建和它的表示分离。使得同样的构建过程,可以创建不同的表示。


 

Builder类是建造者模式的核心,里面包括了构建产品的所有接口(每一个环节)。但Builder通常并不生成最终的构建结果,最终的构建我们通常是放在Director(主管或导演)中


 

可以说是将构建过程和构建结果再次分离。


 

需要注意的是:

Builder 只提供构造一个成品的每一步操作,但并不包含构建最终有产品,比如你想要组装一个自行车,Builder提供了组装一台自行车所需要的所有部件,但至于你如何组装,如何变速,车身结构,颜色等等,这是由Director负责的。


 


 

这里举一个例子:


 

我有一个怪物Monster的对象(Product),他可以包含头,眼睛,嘴,耳朵,手,脚等等很多部分,但怪物的设定,可以是很随意的组合,可以像人,也可以是四不像


 

比如我需要一个似人的怪物,一个长着三只眼睛两条腿的,一只眼睛一只胳膊一条腿的,两个头三张嘴四只脚的......你会发现,组合是多样化的

 

通过建立Builder类,我们将实现构建Monster的每一个环节(添加头,脚,眼睛,腿等),上面提到过,Builder只包含怪物的每一个环节,步骤,但最终构建成什么样子,这需要放在Director中构建。


 

Builder只提供构建最终产品所需要的每一个环节。不包含最终构建的结果。


 

Android里AlertDialog是最为典型的Builder模式的应用,通过不同的组合,一步一步的构建出最后的对话框,并且AlerrDialog代码的设计风格非常的直观,并且组合是自由的,参数之间没有特定的顺序关系,先设置title,后设置title都是不影响的。


 

Builder和Abstract Factory有什么区别?


 

主要区别是:应用场景


 

两者十分相似,但Builder通常是用来构建复杂的对象,并且强调是一步一步的构建,而抽象工厂着重于创建多个系列的产品对象,没有复杂的构造过程。比如不同的UI显示风格,室内的装修风格,游戏的换皮:),主题theme,样式style等


 

Builder建造者模式,则是用于创建一些比如对话框,插花,关卡设计,涉及多种不同组合,而且组合复杂多变化的情况


 

温故而知新,5种创建型模式虽然已经介绍完,但还需要在实际的项目中,多去使用,建立设计模式的意识,多应用,才会有更好的了解,比如思考在你当前的项目里,如何更好的应用到设计模式?


 

如果后面有设计模式比较好的应用,我也会分享上来~

 

感谢您的阅读, 如文中有误,欢迎指正,共同提高


 

欢迎关注我的技术分享的微信公众号,Paddtoning帕丁顿熊,期待和您的交流

 

rhXbxQ0OGVa9RflJweSA.jpg
  • 允许他人重新传播作品,但他人重新传播时必须在所使用作品的正文开头的显著位置,注明用户的姓名、来源及其采用的知识共享协议,并与该作品在磨坊上的原发地址建立链接
  • 可对作品重新编排、修改、节选或者以作品为基础进行创作和发布
  • 可将作品进行商业性使用

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引

游戏学院公众号二维码
腾讯游戏学院
微信公众号

提供更专业的游戏知识学习平台