Silentdoer

导航

工厂模式的本质

转自知乎大神大宽宽的文章:https://www.zhihu.com/question/42975862(禁言了连收藏都不让收藏)

 

【通篇看下来,Factory要解决的是,无法动态创建类型的问题(虽然反射可以),即一个new方法,可以动态传入类型名,部分参数来动态创建该类型名实例和初始化】
【而且还能创建一个接口的子类对象而非new 必须返回实际类型,这也是和构造器模式的大不同】
【还有就是,我们创建出来的抽象类对象或接口有统一方法,比如线程池肯定都有提交task的方法,但是线程池又分为了很多不同点,比如普通的线程池那肯定要填coreSize/maxSize等参数
,而用于Timer功能的则可以不用填这么多,所以也可以直接Executors.newTimerPool(....)来简化入参】
【简单的工厂方法其实就是和new一样,只不过它会在方法内部做业务逻辑的初始化,即构造函数里的参数是需要校验的,如果校验失败是应该异常的,而java里构造方法不应该异常,所以可以放
工厂方法里初始化逻辑和校验】
【这个也有点像接口,比如我对a表的基础查询接口提供了全部字段的查询,这个就类似构造方法;但是还能提供封装后的接口查询,即和业务有关比如是查MM银行用户最近三天的交易记录,这时候这个接口就可以省略很多参数,而且用户也不需要知道哪个参数是银行字段,哪个参数是交易时间字段;这个封装后的接口就类似工厂方法;
你可能会觉得操蛋,只不过少省略了两个参数,为什么要加一个方法?我的回答是:为什么不行,这里一定要注意业务语义,即用户不需要再去关心银行字段,就像用户去了招商银行转账,肯定不需要
再告诉工作人员自己要取哪个银行的钱转账了,只需要告诉工作人员取多少转账即可,然后工作人员会去检查你给的其他参数如金额和目标银行是否合法,或者用户当前的状态是否OK(如被限制消费),当然取钱转账不适合用于描述创建对象,这里只是说明业务语义的重要性;
从这里也能看出和构造器模式的不同,构造器模式创建的对象是没有业务语义的,它本质上就是基础查询接口,如果某个字段没有填则这个字段是用字段类型的默认值如null,0等】
【还有个核心点是创建对象的过程,包括对象的初始化(包括参数校验,字段的初始化不等价于对象本身的初始化)等步骤很复杂,则可以将它放到一个工厂方法里,我们给这个工厂方法取名叫创建某某类型对象,而这个某某类型它可能就包含了一大堆内部字段的不同初始化逻辑;举个例子,创建一个小米手机,参数有型号(8系列),颜色,内存大小;然后手机里不是只有这几个参数,比如内部初始化的时候判断是小米8然后自动用了骁龙855的芯片(初始化参数为提供的字段),然后颜色是紫色,但是内部校验发现8系列没有紫色可能会抛出异常啥的,然后内存如果是16g内部可能自动将内存的牌子调整为国产优质品牌的,如果是8G的可能调整为一般的品牌;即核心点是用户不需要为类里所有字段都初始化,因为它是一个复杂的多字段交互的逻辑,甚至与都不会提供这样的构造函数,只会提供一个工厂方法在创建的同时按业务逻辑再初始化后提供给用户用】
【创建某个类对象并可以使用 这整套 new+init逻辑很复杂,而且甚至这个类里的部分字段不能被外部赋值而是根据创建时的其他字段来自动生成】
 

很多人都会纠结于“既然都有了构造函数,何必再折腾那么多事情呢”。为了解答这个问题,先解释下构造函数是干什么用的。

先用最早出现的C,创建资源差不多要这么干:

some_struct * p = (some_struct*)malloc(sizeof(some_struct));
init_some_struct(p);
do_something(p);

即先分配内存,再做类型转换,再初始化,然后使用。而在OOP的时代,创建一个对象是很频繁的事情。同时,一个没初始化的数据结构是无法使用的。因此,构造函数被发明出来,将分配内存+初始化合并到了一起。如C++的语法是:

SomeClz *p = new SomeClz();
do_something(p); 
// or
p.do_something_else();

java也沿用了这个设计。

但是,整个构造函数完成的工作从更高层的代码设计角度还是太过于初级。因此复杂的创建逻辑还是需要写代码来控制。所以还是需要:

// 这段代码可以这么理解,创建一个类型(struct),在参数里定义这个struct的结构,比如占用多少字节(比如是128个字节),每个字节是干嘛的之类的(但是SomeClz本身可以不止128字节或没有128字节)
// 然后创建了这样一个结构后,再new SomeClz()就能根据上面createSomeClz方法定义的结构SomeClz来真正创建 “对象”,即创建一个大小128字节的空间出来
SomeClz * createSomeClz(...) {
 // 做一些逻辑
 SomeClz *p = new SomeClz(); // 或者复用已经有的对象
 // 再做一些额外的初始化
 return p;
}

 这就是Factory的雏形。

Factroy要解决的问题是:希望能够创建一个对象,但创建过程比较复杂,希望对外隐藏这些细节。

请特别留意“创建过程比较复杂“这个条件。如果不复杂,用构造函数就够了。比如你想用一个HashMap时也要搞一个factory,这就很中2了。

好,那什么是“复杂的创建过程呢“?举几个例子:

例子1: 创建对象可能是一个pool里的,不是每次都凭空创建一个新的。而pool的大小等参数可以用另外的逻辑去控制。比如连接池对象,线程池对象就是个很好的例子。

例子2: 对象代码的作者希望隐藏对象真实的的类型,而构造函数一定要真实的类名才能用。比如作者提供了【避免到处都是对象实例化,万一哪天需要换一个类,就得一个个去改,还不如将实例化放到一个统一的封装入口】

abstract class BaseFoo { 
 //...
}

而真实的实现类是

public class FooImplV1 extends BaseFoo {
  // ...
}

但他不希望你知道FooImplV1的存在【至少是明面上的】(没准下次就改成V2了),只希望你知道BaseFoo,所以他必须提供某种类似于这样的方式让你用:

BaseFoo foo = FooCreator.create();
// do something with foo ...

例子3: 对象创建时会有很多参数来决定如何创建出这个对象。比如你有一个数据写在文件里,可能是xml也可能是json。这个文件的数据可以变成一个对象,大概就可以搞成。

Foo foo = FooCreator.fromFile("/path/to/the/data-file.ext");

再比如这个文件是描述一个可以显示在浏览器的UI的基础数据。而不同浏览器可以正确显示的需要的数据不太一样。这个“不一样”可以表达为:

Foo foo = FooCreator.fromFile("/path/to/the/data-file.ext", BrowserType.CHROME);

这里第二个参数"BrowserType"是一个枚举,表示如何去生成指定要求的对象。所以这个fromFile内部可能是:

public Foo fromFile(String path, BrowserType type) {
  byte[] bytes = Files.load(path);
  switch (type) {
     case CHROME: return new FooChromeImpl(bytes);
     case IE8: return new FooIE8V1Impl(bytes);
     // ...
  }
}    

当然,实际场景可能会复杂得多,会有大量的配置参数。

Foo foo = FooCreator.fromFile("....", param1, param2, param3, ...);

如果需要,可以帮params弄成一个Config对象。而如果这个Config对象也很复杂,也许还得给Config弄个Factory。如果Factory本身的创建也挺复杂呢?嗯,弄个Factory的Factory。

例子4:简化一些常规的创建过程。上面可以看到根据配置去创建一个对象也很复杂。但可能95%的情况我们就创建某个特定类型的对象。这时可以弄个函数直接省略那些配置过程。纯粹就是为了方便。

Foo foo = FooCreator.chromeFromFile("/path/to/the/date-file.ext");

现实当中,比如Java的线程池的相关创建api(如Executors.newFixedThreadPool等)就是这么干的。

例子5: 创建一个对象有复杂的依赖关系,比如Foo对象的创建依赖A,A又依赖B,B又依赖C……。于是创建过程是一组对象的的创建和注入。手写太麻烦了。所以要把创建过程本身做很好地维护。对,Spring IoC就是这么干的。

例子6: 你知道怎么创建一个对象,但是无法把控创建的时机。你需要把“如何创建”的代码塞给“负责什么时候创建”的代码。后者在适当的时机,就回调创建的函数。

在支持用函数传参的语言,比如js,go等,直接塞创建函数就行了。对于名词王国

java,就得搞个XXXXFactory的类再去传。Spring IoC 也利用了这个机制,可以了解下FactoryBean

例子7: 避免在构造函数

中抛出异常。"构造函数里不要抛出异常"这条原则很多人都知道。不在这里展开讨论。但问题是,业务要求必须在这里抛一个异常怎么办?就像上面的Foo要求从文件读出来数据并创建对象。但如果文件不存在或者磁盘有问题读不出来都会抛异常。因此用FooCreator.fromFile这个工厂来搞定异常这件事。

其实还有很多例子,就不继续扩展了。要点是,当你有任何复杂的的创建对象过程时,你都需要写一个某种createXXXX的函数帮你实现。再拓展一下范围,哪怕创建的不是对象,而是任何资源,也都得这么干。一句话:

不管你用什么语言,创建什么资源。当你开始为“创建”本身写代码的时候,就是在使用“工厂模式”了。

具体形式可以根据当时的场景去调整,不管你用的是静态函数抽象类还是模版等,那都是细节。不同语言的支持也不太一样。比如Java这方面就略微土一些,函数不是一等公民限制了表达力。所以你会看到各种XXXXFactory,AbstractXXXXFactory的类。

kotlin提倡用静态工厂方法解决一部分问题,即给一个class的companion object做一个表示工厂的函数。在Effective Koltin第一条就是这个
interface ImageReader {
    fun read(file: File): Bitmap

    companion object {
        // 提供静态工厂方法
        fun newImageReader(format: String) = when (format) {
            "jpg" -> JpegReader()
            "gif" -> GifReader()
            else -> throw IllegalStateException("Unknown format")
        }
    }
}

// 使用静态工厂
而对于go,一般用一个函数去创建一个初始化好的对象(或者叫struct?)。go的想法很简单:反正你总是要写一个函数,就写函数吧,不要搞出那么多幺蛾子概念
type SomeStruct struct {
 // ...
}

func NewSomeStruct() *SomeStruct {
    s := SomeStruct{...}
    // 做一些初始化
    return &s
}

最后特别提醒下初学者,我很理解你们刚学到了一招马上就想试试的心情,但如果是上生产,请总是使用可以满足需求的最简单的方案。不要为了工厂模式而工厂模式。搞工厂这么一套(或者任何其他模式)都是有成本的。开闭原则是没错,但只应该在合适的时候使用。更麻烦的是假如你一开始搞错了,做出来的工厂的接口抽象后来发现是不符合需求变更,改起来还不如一开始没有做工厂,直接new。越简单的代码越容易改,哪怕看起来会有些体力劳动,但不费神。当然,这也不是说尽量不要用模式。这完全取决于你对需求的理解。所以多花时间理解需求和业务,然后问自己“这里可能会变得很复杂吗?这里未来3个月多大可能需要扩展?”

同时也不要照着《设计模式》去写代码。你可以将《设计模式》理解为是一本字典。它的内容是没错,但一般只用来做参考。对于一个模式要不要用,怎么用,要看场景。正常写文章的人,除非是学生,没人会在写文章的时候抱着本字典去写,对吧。


作者:大宽宽
链接:https://www.zhihu.com/question/42975862/answer/1239305317
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

posted on 2023-11-12 17:42  Silentdoer  阅读(6)  评论(0编辑  收藏  举报