2.3 DSL实现模式

DSL开发实践中存在数量众多的架构模式,简单地把DSL按内部外部分类实在过于宽泛。内部DSL的共性是都建立在宿主语言之上。外部DSL的共性是都重新建立一套语言设施。起源上的共性并不足以全面刻画DSL分类体系。我们从第1章看到,DSL是一系列优秀抽象设计原则的化身。而设计优秀的抽象,不仅需要考虑各构成元素在形式上的共性,还要考虑每个元素展现出来的差异性

接下来,我们将展示一些体现了差异性的实现模式。即使是同一类别的DSL也存在多种多样的架构模式,只有了解它们,你才能更好地针对DSL需求找到合适的具体实现架构。对那些一再出现的模式发掘得越多,你越容易重用自己的抽象。本书附录A讨论过这样的抽象设计原则,即当抽象可以被重用时,用它们设计出来的语言也会更容易扩展。如果你还没有读过附录A,请务必现在翻看。附录A中的知识对于本书后面的学习历程是很有帮助的。

2.3.1 内部DSL模式:共性与差异性

内部DSL其实随处可见。像Ruby和Groovy这类语言既有灵活而简洁的语法,又有强大的元编程模型,在语言的强力支持下,在用它们写成的软件中几乎都可以找到DSL的身影。所有内部DSL都有个共同模式,它们总是在现有宿主语言之上实现的。提到内部DSL的时候我更喜欢说成内嵌DSL,因为这可以突显它的一个架构特征。如果用不同的方式去运用宿主语言提供的设施,你创作出来的DSL也会在形式、结构、灵活性和表现力等方面表现出差异。

内部DSL主要有如下两种形态。

  • 生成式 领域专用的结构体经过编译时宏、预处理器或某种运行时MOP(Meta-Object Protocol,元对象协议)的转换生成实现语言的代码。

  • 内嵌式 领域专用的类型内嵌于宿主语言的类型系统。

即使这么细分也免不了有模棱两可的情况。比如Ruby及其Web框架Rails。Rails是一种用Ruby语言写成的内部DSL,可以说Rails内嵌于Ruby。但同时Rails又运用了Ruby的元编程能力,以便在运行时生成大量代码,所以它又是生成式的。

还有一类静态类型语言单纯将DSL内嵌于宿主语言的类型系统,Haskell和Scala是其中的代表。在这样的语言中,DSL继承了宿主类型系统的全部能力。另外,Haskell有一种语言扩展(Template Haskell)为语言增加了生成式能力,而这是通过宏来实现的。

仅在内部DSL这一类别下就有数量众多的不同形态模式,有的语言还同时支持多种DSL开发范式。图2-2展示了内部DSL的一个分类图谱,并且在每一种模式旁边标注了支持它的一些语言。

2.3 DSL实现模式 - 图1

图2-2 内部DSL的各种实现模式,不太严谨的分类

下面我们就参考图2-2逐一介绍这些常见的内部DSL实现手段。

2.3 DSL实现模式 - 图2第4章和第5章可作为本节材料的补充阅读,这两章将更加详细深入地阐释DSL的模式与实现。

1. 灵巧API

灵巧API(Smart API)可能是最简单也最常见的DSL实现技术。这种技术的基础是方法串联,近似于实现Builder模式(参见2.6节参考文献[1])。Martin Fowler将灵巧API称作连贯接口http://www.martinfowler.com/bliki/FluentInterface.html)。在这种模式下,你所创建的API会按(要建模的)领域动作的自然序列串联起来。环环相扣的动作自然产生连贯接口,而具有领域含义的方法名可以方便领域用户的阅读和理解。下面的代码片段来自Guice API(http://code.google.com/p/google-guice/),它是谷歌开发的一个依赖注入(DI)框架。假设用户打算声明一个应用模块,把一个具体实现绑定到某Java接口,用Guice API写出来就是下面这样子,它看起来流畅自然,而且清楚地表达了用例的意图:

  1. binder.bind(Service.class).to(ServiceImpl.class).in(Scopes.SINGLETON)

图2-3表现了API一环套一环的样子,调用得到的返回对象立刻又在其上发出另一个调用。

2.3 DSL实现模式 - 图3

图2-3 以方法串联手法实现的灵巧API。方法调用一个接一个,并直到最后才返回给客户

通过方法串联手法,我们利用宿主语言的设施和领域语汇完成了灵巧API的构造。但这种手法有个缺点,它很容易导致大量可能没有太大独立存在意义的小方法。而且,不见得所有的用例都适合用连贯接口实现。若通过Builder模式增量构造并配置对象,一般方法串联的建模方式表现效果最好,2.1节中用Java实现的交易单处理DSL就是很好的例子。不过在Groovy、Ruby等语言中,由于其提供了“命名参数”特性,Builder模式和连贯接口显得有些多余。(Scala 2.8也支持带默认值的命名参数。)举个例子,刚才那段Java代码如果改成Groovy版本,只是混用一般的参数和命名参数而已,代码立即就简洁了许多,但表现力一点都不输原来的版本:

  1. binder.bind Service, to: ServiceImpl, in: Scopes.SINGLETON

灵巧API是内部DSL常用的一种模式。具体的实现取决于所用的语言。这里有个需要牢记的要点:实现DSL模式的时候,应该总是选择最合乎语言习惯的实现手法。第4章讨论到连贯接口在内部DSL实现中的作用时,我还会针对这个主题补充更多的例子和不同的实现方式。现在,我们接着看下一个模式。

2. 语法树操控

语法树操控也是用来实现内部DSL的一种手段。它按照Interpreter模式(参见2.6节参考文献[1]),利用宿主语言的设施来生成并操控AST(Abstract Syntax Tree,抽象语法树)。AST产生之后,开发者在语法树上遍历,安插、修改AST的结构,把根据领域逻辑希望得到的代码反映在AST的结构上。Groovy和Ruby都提供了这方面的设施,可以通过库来操作AST以生成代码。

你有没有想起来,这种实现手段其实是Lisp语言天生就具备的能力。在Lisp语言里,每个程序都是一个列表结构,而这些列表结构就是程序员可以操控的AST。Lisp宏所做的事情归根结底就是通过操控AST来生成代码。程序员可以用这种手段扩展Lisp语言的核心语法。

3. 类型化内嵌

以元编程为基础的DSL实现模式依靠代码生成技术保持语言界面的抽象层次,使之精确地满足领域要求。但如果选用的宿主语言不支持任何形式的元编程,该怎么办?在设计DSL的时候,你必须秉持极简的方针以决定哪些东西可以作为专用语法展现给用户,这是极重要的设计要求。宿主语言的设施提供的抽象手段越多,开发者越容易实现极简的目标。

静态类型语言把类型作为对领域语义的一种抽象手段,同时使DSL的表面语法简洁精炼。这种方式不通过生成代码来表达领域行为,而是以宿主语言的类型和操作为载体定义并实现领域专用类型。这些专用类型即构成用户面对的DSL语言界面;注意,用户并不在意类型背后的具体实现。类型化的模型在一定程度上隐式地保证了编程模型的一致性。图2-4简单说明了类型带给DSL的好处。

enter image description here

图2-4 嵌入了类型约束的DSL在很多方面隐式地保证一致性。请用类型对DSL抽象建模。在类型中定义的约束条件自动获得编译器的检查,检查甚至发生在程序运行之前

这种模式的最大优点在于:因为DSL的类型系统内嵌于宿主语言的类型系统,所以其类型系统会自动获得宿主语言编译器的语法检查。DSL的用户也可充分利用IDE集成的各种宿主语言功能,诸如智能提醒、代码补全、重构等。

下面的Scala示例是对Trade抽象的建模。该示例中,TradeAccountInstrument都是封装了业务规则❷的领域专用类型❶。在Ruby或Groovy里面,领域行为借由生成额外代码实现;在Scala里面,类似的语义实现于类型载体之上,并由编译器保障一致性。

  1. trait Trade { ❶对领域类型的抽象
  2. type Instrument
  3. type Account
  4. def account: Account
  5. def instrument: Instrument
  6. def valueOf(a: Account, i: Instrument): BigDecimal 对领域操作的抽象
  7. def principalOf: BigDecimal
  8. def valueDate: Date
  9. //...
  10. }

Haskell和Scala等语言具有高级静态类型化能力,使你完全可以设计出单纯的类型化内嵌式DSL,无需求诸于代码生成、预处理器或者宏技术。DSL用户可以通过语言本身实现的组合子,把类型化的抽象组织成DSL语句。这类语言的类型系统拥有一些高级功能,比如支持类型推导、高阶抽象等,可使语言简洁又不失表现力。Paul Hudak在1998年即用Haskell语言演示过这种DSL实现方式(参见2.6节参考文献[2]),当时他用Monad化的语言解释器设计、部分求值技术和多阶段编程(staged programming)技术来实现可以增量式演进的纯内嵌式DSL。Christian Hofer等人也在2.6节参考文献[3]中讨论了Scala的类似实现,甚至还论及如何巧妙利用traits、虚类型、高阶泛型、族多态等Scala特性在单一DSL界面下“多态地”嵌入多个实现。在第6章,我将用一些实现范例说明Scala的静态类型对于设计纯粹的EDSL(Embedded Domain-Specific Language,内嵌式领域专用语言)的帮助。

定义 Monad代表一种经由Haskell语言得到推广的计算模型。Monad让你按照预定义的规则去构建抽象。第6章在讲述用Scala实现DSL的内容时会探讨Monad化的程序结构。更详细的定义请参阅http://en.wikipedia.org/wiki/Monad_(functional_programming)

接下来要讲述的几种常见DSL实现模式都属于元编程模式,其支持语言的兴盛与它们息息相关。在不同的DSL实现技术中,若论定制DSL语法的能力,元编程可谓当仁不让。

4. 反射式元编程

有些模式针对小范围的局部实现,比如灵巧API。而针对大范围的一般实现策略,也有一些模式可以遵循。后一类模式不但对实现的整体结构有决定性影响,而且本身就是宿主语言的关键特性。回顾我们讨论内部DSL实现模式的过程(参见图2-1所示的路线图),元编程的概念一再以各种面目现身于DSL设计之中。其中一种形态——反射式元编程——正是我们这一节所要讨论的模式。

假设你要设计一种DSL用来读取配置文件,然后根据配置内容动态地调用若干方法。下面是真实的Ruby示例,它从一个YAML文件读取方法的名称和参数,然后用读到的参数动态地调用方法:

  1. YAML.load_file(x_path).each do |k, v|
  2. foo.send("#{k}", v) unless foo.send(k)
  3. end

因为直到运行时才能得知方法名称,我们要用到Ruby的元编程能力,通过Object#send()语法动态地向对象发出消息,这有别于一般静态方法调用的点符号语法。这里所用的编码技巧就叫反射式元编程;Ruby在运行时获知方法,然后调用。在DSL实现中处理动态对象的时候,你就可以利用这种技巧推迟方法调用,直到从配置文件等途径收集到全部所需信息。

5. 运行时元编程

反射式元编程只能发现运行之前已经存在的方法,不过有的元编程形式能够在运行期间动态地生成新代码。运行时元编程也是精简DSL表面语法的一个途径。它使DSL外观上显得轻巧,把费力的代码生成工作被转移到宿主语言的后端设施去处理。

有些语言会将它们的运行时基础组件暴露出来,使之成为程序员可以操控的元对象。在Ruby或Groovy语言中,程序可以利用这些组件在运行时动态改变元对象的行为,或者注入新行为去实现领域结构。图2-5简单说明了Ruby和Groovy中元编程的运行时行为。

enter image description here

图2-5 支持运行时元编程的语言允许代码一边生成一边执行。生成的代码可以修改已有的类和对象,动态地增加新行为

我们在2.1节用Groovy开发过交易单处理DSL,当时就用到了这里所说的运行时元编程技术在内置类Integer上增加新方法sharesof。实际上对于DSL打算执行的动作语义,这两个方法并没有任何实质意义。它们只起到一种连结作用,目的是让语言更贴近建模领域。图2-6是一段用Groovy实现的DSL语句,图上为这一串连续调用的方法标注了每个方法的返回类型。新生成的方法贯穿于语句中间,大大改善了语言的表达能力,这些都是运行时元编程的效果。

enter image description here

图2-6 通过运行时元编程获得丰富的领域语法

Rails和Grails是两个特别强大的Web开发框架,它们都借助了运行时元编程的力量。在Rails中,你只要写下下面的代码,Ruby的元编程引擎会根据Employees表的定义生成所有相关的关系模型及验证逻辑代码。

  1. class Employee < ActiveRecord::Base {
  2. has_many :dependants
  3. belongs_to :organization
  4. validates_presence_of :last_name, :title, :date_of_birth
  5. # ...
  6. }

运行时元编程通过在运行时阶段生成代码使DSL动态化。不过,还有另一种形态的代码生成技术,它发生在编译阶段,因而不会给运行时增加任何额外开销。下面我们要讨论的DSL模式就是编译时元编程,这种模式大多出现于Lisp语言家族。

6. 编译时元编程

若采用编译时元编程,我们可以为DSL加上定制语法,这点跟刚刚讨论过的运行时元编程很像。虽然两种模式有相似的地方,但也存在着关键的区别,请看表2-3。

表2-3 编译时元编程与运行时元编程的对比

编译时元编程 运行时元编程
开发者定义的语法在运行时之前、编译阶段得到处理 开发者定义的语法在运行时期间经过元对象协议(MOP)处理
没有运行时的额外开销,因为到达运行时平台的都是正常形式的程序 有一定程度的运行时开销,因为要处理元对象和生成代码

在编译时元编程的典型实现中,用户通过与编译器的交互在编译阶段生成程序片段。

enter image description here宏是最常见的一种编译时元编程实现途径。4.5节将通过Clojure示例深入解释编译时元编程的工作原理。

C语言中基于预处理器的宏,还有C++语言中的模板,都是能在编译阶段生成代码的语言基础结构。不过,如果追溯编程语言的历史,Lisp才是编译时元编程的鼻祖。C语言宏是在词法层面上进行文本替换操作。而Lisp宏直接对AST操作,在句法层面上提供了极强的抽象设计能力。从图2-7中的示意图可知,开发者定制的DSL专用语法经过宏展开阶段的处理变成普通的代码成分,然后被转发给编译器。

enter image description here

图2-7 用宏来实现编译时元编程。DSL脚本中有一部分是普通的宿主语言成分,有一部分是开发者定制的专用语法。定制部分以宏的形式出现,在宏展开阶段展开成正常的语言形式。然后,所有代码被一起送给编译器

本章对内部DSL实现模式的讨论到此为止。我们介绍的几种元编程方式主要见于Ruby、Groovy、Clojure等动态语言。另外,我们介绍了静态类型化技术,以及它对于设计类型安全的DSL的作用。第4章~第6章会再次回顾这里介绍的所有模式,并在各种语言的具体示例佐证下展开讨论。

本章一开头我们就承诺过要探讨现实中的DSL设计。前面已经讨论过用Java和Groovy完成的DSL实现,刚刚又介绍了内部DSL实现中出现的模式。每个模式都是实践者应该勇于重用的经验片段。所有这些模式都是在现实的DSL开发中成功实践过的。

说完内部DSL,显然接下来我们就要说外部DSL了。假如宿主语言无法实现你想要的DSL语法形态,那就应该跳脱宿主语言的桎梏,不惜选择需要从零开始的实现途径。外部DSL正是正确答案。接下来,我们就来看看外部DSL有哪些实现模式。

2.3.2 外部DSL模式:共性与差异性

外部DSL的设计周期和原则与通用语言设计相同。我知道这个说法肯定让你望而生畏,甚至打消你在项目中设计外部DSL的念头。这句论断在理论上完全成立,只不过DSL的语法和语义并不需要像通用编程语言那么复杂,所以其实没有那么吓人。在现实中,我们可能只需要动用正则表达式来操控一下字符串,就足以实现外部DSL的处理。不论简单还是复杂,总之所有外部DSL的共同特征就是其实现不借助宿主语言的设施。

外部DSL的处理过程可以粗略分为两个阶段,如图2-8所示。

解析 对输入的文本进行分词,通过解析器识别有效的输入。

加工 解析器识别出的有效输入在此接受处理。

enter image description here

图2-8 外部DSL的处理阶段。请注意与内部DSL的区别:在这里开发者需要自行构建解析器;而对于内部DSL,解析器由宿主语言提供

如果DSL比较简单,解析器可以就地完成加工工作,那么两个阶段可以合二为一。不过,一般而言比较实际的做法是让解析器把输入文本转换成一种中间表示。根据具体情况以及DSL的复杂程度,这种中间表示可以是AST,也可以是其他更复杂的语言元模型。解析器本身的复杂程度也不同,简单的只是字符串处理而已,复杂的也许要用到精密的语法制导翻译(syntax-directed translation)技术(这种解析技术将在第8章讨论),需要动用YACC和ANTLR等“解析器生成器”来制作。加工阶段围绕中间表示来进行,可以直接从中间表示生成目标输出,也可以将之转化为一种内部DSL,然后用宿主语言的设施处理。

接下来,我们简单看下外部DSL开发中较常遇到的几种模式。每种模式的具体实现留到第7章再作详细介绍。图2-9列举了一些现实中常见的外部DSL实现模式。

enter image description here

图2-9 常见的外部DSL实现模式和技巧,不太严谨的分类

图2-9中的每种模式都是一种描述DSL语法的方式,而且是不同于宿主语言的描述方式。也就是说你写下的DSL脚本,放在实现语言里面,并不是有效的语法。所以你还会看到,每种模式下产生的定制DSL语法如何被转换成为供给宿主语言使用的制品。

1. 上下文驱动的字符串操控

假设你需要处理一些业务规则,但希望向用户提供一个DSL界面来取代传统的API。考虑下面的例子(commission为“佣金”,principal为“本金”):

  1. commission of 5% on principal amount for trade
  2. values greater than $1,000,000

这个字符串放在任何编程语言里都没有意义。但只要进行适当的“揉按拍打”,我们不难把它转换成有效的Ruby或Groovy代码。一个简单的解析器就可以完成任务,只需要做一下分词,然后套上正则表达式做简单的转换就可以了。最后得到的Ruby或Groovy代码就是可以直接执行的业务规则了。

2. XML转换成可使用的资源

大多数读者应该都用过Spring DI框架(不熟悉Spring的读者请查阅http://www.springframework.org)。其中一种配置DI容器的方式是采用一个XML配置文件,把所有依赖的抽象和实现都记入这个文件。在运行时,Spring容器载入配置文件,并将所有的依赖项关联到BeanFactory或者ApplicationContext,这两个组件将在应用程序的整个生命周期内存活,以提供所有必需的上下文信息。这个XML配置文件就是一种外部DSL,它经过解析,被持久化为可直接供应用程序使用的资源。

图2-10简单展示了Spring将XML作为外部DSL导入其ApplicationContext抽象的情形。

enter image description here

图2-10 XML被用作外部DSL,它是对Spring配置规范的抽象。容器在启动期间读入并解析XML,产生应用程序所需的ApplicationContext

Hibernate的映射文件是一个类似的例子,它把实体描述文件映射到数据库设计方案。(Hibernate的内容详见http://hibernate.org。)虽然两个例子的生命周期和持久化策略有所差异,但它们都具有解析、加工两个执行阶段,这一点体现了外部DSL的共性。另一方面,这两个例子又表现出与其他模式的差异性,即其解析器的形式与复杂度、中间表示的生命周期都有别于前面讨论过的模式(上下文驱动的字符串操控)。

3. DSL工作台

我们在内部DSL的语境下讨论过元编程的核心概念,现在出现的一些语言工作台和元编程系统已经把这个概念的内涵扩展到了更高层次。编写文本形式的代码时,编译器需要解析代码并生成AST。那要是系统直接就把你写的代码以AST的形式来存放呢?如果系统能提供这样的中间表示,对其进行的转换、操控以及后续(以这种中间表示为基础)的代码生成都会简单很多。

Eclipse Xtext(http://www.eclipse.org/Xtext)是个很好的例子,它提供了开发外部DSL的全套解决方案。在这个系统中,DSL不是保存成纯文本形式,而是以元模型的形式保存DSL文法的高阶表示。这些元模型可以无缝地集成进其他框架,如代码生成器、编辑器等。像Xtext这样的工具叫做DSL工作台,因为它们提供了开发、管理、维护外部DSL的完整环境。第7章将通过详细的案例分析讲解基于Xtext的DSL设计。

JetBrains的Meta Programming System(http://www.jetbrains.com/mps/index.html)支持非文本表示的程序代码,不再需要代码解析。代码及其标注、引用总是以AST的形态存在,你只要定义合适的生成器就能生产出各种语言的代码。这就好像我们在使用工作台的元编程系统所提供的元语言设计外部DSL,用它来定义业务规则、类型、约束,跟使用平常的编程语言没什么两样。只是代码的外部表示不一样,它更友好也更容易操控,甚至可以是图形化的。

回顾图2-9,我们已经介绍了3种常用的外部DSL实现手段,还有两种在现实中特别重要的模式有待讲解。本章的第三个里程碑已经在望。对于迄今介绍的所有内部、外部DSL实现技术,相信你已经有了很好的理解,想必已经迫不及待地想看到它们在领域建模中的实际表现。请再耐心一点,你会很快就会看到相关内容。

4. DSL中内嵌异质代码

解析器生成工具如YACC和ANTLR让程序员使用类似于EBNF(Extended Backus-Naur Form,扩展巴科斯-瑙尔范式)的语法符号来定义语言的语法。生成工具“吃进”产生规则,“吐出”语言解析器。在实现解析器的时候,我们一般会定义一些操作,让解析器在识别到某些输入片段的时候执行,例如要求解析器对于输入的语言字符串生成中间表示,供应用程序在后续环节使用。

YACC和ANTLR等工具允许在生成规则中嵌入宿主语言代码编写的操作定义。最终得到的解析器代码也将每一条规则下关联的C、C++或Java片段囊括在内。在这种外部DSL设计模式下,DSL因为嵌入了别种高级语言而得到扩展。我们将在第7章把ANTLR作为解析器生成工具,按照这个模式实现一个完整的DSL设计。现在赶紧看看最后一种模式吧。

5. 基于解析器组合子的DSL设计

这是图2-9列出的最后一种模式,也是最有革新意义的外部DSL设计方法。刚刚你已经知道怎样利用YACC、ANTLR等外部工具结合内嵌编程语言来生成DSL解析器。这些工具完全胜任工作,但缺点是使用上不很友好。现在有不少语言提供了更好的替代品,也就是解析器组合子(parser combinator)。

在强大的类型系统支持下,利用解析器组合子设计而成的DSL可以实现为宿主语言的一个库。也就是说,实现过程中可以充分利用宿主语言的各种配件,例如类、方法、组合子等,不必求助于外部工具包。

Scala的标准库中已经包含了解析器组合子库。在Scala的高阶函数帮助下,我们定义的组合子可以使解析器的描述语句近似于EBNF产生规则。下面就是一段Scala解析器组合子的应用示例,它用纯Scala语言定义了一种简单的交易单处理语言的语法。

  1. object OrderDSL extends StandardTokenParsers {
  2. lexical.delimiters ++= List("(", ")", ",")
  3. lexical.reserved += ("buy", "sell", "shares", "at",
  4. "max", "min", "for", "trading", "account")
  5. def instr = trans ~ account_spec
  6. def trans = "(" ~> repsep(trans_spec, ",") <~ ")"
  7. def trans_spec = buy_sell ~ buy_sell_instr
  8. def account_spec = "for" ~> "trading" ~> "account" ~> stringLit
  9. def buy_sell = ("buy" | "sell")
  10. def buy_sell_instr = security_spec ~ price_spec
  11. def security_spec = numericLit ~ ident ~ "shares"
  12. def price_spec = "at" ~ ("min" | "max") ~ numericLit
  13. }

即使看不懂这段代码的细节部分也没关系。这所以举这个例子,我只是为了展示一下纯以宿主语言完成声明式解析器开发这一方式有多大威力。结论是纯粹用Scala开发外部DSL解析器完全可行。

enter image description here第8章将再次讨论解析器组合子,其中会有通过Scala解析器组合子建立外部DSL的详细示例。

我们的讨论至此告一段落,前面列举过的所有DSL实现模式和技巧都已介绍完毕。本章的介绍都是概括性的,目的是使读者对现实中的DSL实现大环境有所了解。至于每种模式的详细用法,我们安排在第4章~第8章讲解,届时会用它们实现DSL以解决现实的领域问题。

在本章结束之前,我们最后考虑一个具有现实意义的问题,也是你每次动手设计DSL之前应该考虑的问题:怎样才能务实地决定选择哪种形式的DSL。第1章已经讨论过什么时候该用DSL,现在我要解释下该如何在内部DSL和外部DSL之间进行选择。用DSL来建模领域问题肯定没错,但同时要平衡考虑它的实现方面才好作出最终决定。无论决定设计一个内部DSL还是外部DSL,都取决于许多因素;而且并非所有因素都是技术因素。