声明式编程:这是真的吗?
已发表: 2022-03-11目前,声明式编程是数据库、模板和配置管理等广泛而多样的领域的主导范式。
简而言之,声明式编程包括指示程序需要做什么,而不是告诉它如何去做。 在实践中,这种方法需要提供一种域特定语言 (DSL) 来表达用户想要的内容,并将它们与实现所需最终状态的低级构造(循环、条件、赋值)隔离开来。
虽然这种范式比它所取代的命令式方法有了显着的改进,但我认为声明式编程有很大的局限性,我将在本文中探讨这些局限性。 此外,我提出了一种双重方法,它既能获得声明式编程的好处,又能克服其局限性。
CAVEAT :这篇文章是个人多年与声明性工具斗争的结果。 我在这里提出的许多主张都没有得到彻底的证实,有些甚至是表面上的。 对声明式编程进行适当的批评需要相当多的时间和精力,而且我必须回去使用其中的许多工具; 我的心不在这样的事业中。 本文的目的是与您分享一些想法,不费吹灰之力,并展示对我有用的方法。 如果您一直在为声明式编程工具而苦恼,您可能会找到喘息的机会和替代方案。 如果你喜欢这个范式和它的工具,别把我当回事。
如果声明式编程对您很有效,那么我无法告诉您其他情况。
声明式编程的优点
在我们探索声明式编程的局限性之前,有必要了解它的优点。
可以说,最成功的声明式编程工具是关系数据库 (RDB)。 它甚至可能是第一个声明性工具。 在任何情况下,RDB 都表现出我认为是声明式编程原型的两个属性:
- 领域特定语言 (DSL) :关系数据库的通用接口是一种名为结构化查询语言的 DSL,通常称为 SQL。
- DSL 对用户隐藏了较低级别的层:自从 Edgar F. Codd 关于 RDB 的原始论文以来,很明显,该模型的功能是将所需查询与实现它们的底层循环、索引和访问路径分离。
在 RDB 之前,大多数数据库系统都是通过命令式代码访问的,这在很大程度上依赖于低级细节,例如记录的顺序、索引和数据本身的物理路径。 由于这些元素会随着时间而变化,因此代码通常会因为数据结构的某些潜在变化而停止工作。 生成的代码难以编写、难以调试、难以阅读和难以维护。 我敢说,大部分代码很可能很长,充满了众所周知的条件句、重复和微妙的、依赖于状态的错误的老鼠巢。
面对这种情况,RDB 为系统开发人员提供了巨大的生产力飞跃。 现在,您不再需要数千行命令式代码,而是拥有明确定义的数据方案,以及数百(甚至数十)条查询。 因此,应用程序只需处理抽象的、有意义的和持久的数据表示,并通过强大而简单的查询语言进行接口。 RDB 可能将程序员和雇用他们的公司的生产力提高了一个数量级。
声明式编程通常列出的优点是什么?
- 可读性/可用性:DSL 通常更接近自然语言(如英语)而不是伪代码,因此更具可读性并且非程序员也更容易学习。
- 简洁:大部分样板文件都被 DSL 抽象出来,只剩下更少的行来做同样的工作。
- 重用:更容易创建可用于不同目的的代码; 在使用命令式构造时,这是出了名的困难。
- 幂等性:您可以使用最终状态并让程序为您解决问题。 例如,通过 upsert 操作,您可以插入不存在的行,或者如果已经存在则修改它,而不是编写代码来处理这两种情况。
- 错误恢复:很容易指定一个将在第一个错误处停止的构造,而不必为每个可能的错误添加错误侦听器。 (如果你曾经在 node.js 中编写过三个嵌套回调,你就会明白我的意思。)
- 引用透明性:虽然这个优势通常与函数式编程相关联,但它实际上适用于任何最小化手动处理状态并依赖副作用的方法。
- 交换性:表达最终状态的可能性,而不必指定将要实现的实际顺序。
虽然以上都是声明式编程的常见优点,但我想将它们浓缩为两种品质,当我提出另一种方法时,它们将作为指导原则。
- 为特定领域量身定制的高级层:声明式编程使用它所应用的领域的信息创建一个高级层。 很明显,如果我们正在处理数据库,我们需要一组操作来处理数据。 上述七个优势中的大多数都源于创建了一个针对特定问题域精确定制的高级层。
- Poka-yoke (fool-proofness) :为领域量身定制的高级层隐藏了实现的必要细节。 这意味着您犯的错误要少得多,因为系统的低级细节根本无法访问。 此限制消除了代码中的许多类别的错误。
声明式编程的两个问题
在接下来的两节中,我将介绍声明式编程的两个主要问题:分离性和缺乏展开。 每个批评都需要它的怪物,所以我将使用 HTML 模板系统作为声明式编程缺点的具体示例。
DSL 的问题:分离性
想象一下,您需要编写一个包含大量视图的 Web 应用程序。 将这些视图硬编码为一组 HTML 文件不是一种选择,因为这些页面的许多组件都发生了变化。
最直接的解决方案是通过连接字符串来生成 HTML,这看起来太可怕了,以至于您很快就会寻找替代方案。 标准解决方案是使用模板系统。 尽管存在不同类型的模板系统,但出于分析的目的,我们将回避它们的差异。 我们可以认为它们都是相似的,因为模板系统的主要任务是为使用条件和循环连接 HTML 字符串的代码提供替代方案,就像 RDB 作为循环数据记录的代码的替代方案一样出现。
假设我们使用标准模板系统; 你会遇到三个摩擦源,我将按重要性升序排列。 首先是模板必须位于与您的代码分开的文件中。 因为模板系统使用的是DSL,语法不同,所以不能在同一个文件中。 在文件数量较少的简单项目中,需要保留单独的模板文件可能会使文件数量重复或增加三倍。
我为嵌入式 Ruby 模板 (ERB) 打开了一个例外,因为它们已集成到 Ruby 源代码中。 对于以其他语言编写的受 ERB 启发的工具而言,情况并非如此,因为这些模板也必须存储为不同的文件。
摩擦的第二个来源是 DSL 有自己的语法,与您的编程语言的语法不同。 因此,修改 DSL(更不用说编写自己的)要困难得多。 要深入了解并更改工具,您需要了解标记化和解析,这很有趣且具有挑战性,但很难。 我碰巧认为这是一个缺点。
你可能会问,“你到底为什么要修改你的工具? 如果您正在做一个标准项目,那么一个编写良好的标准工具应该符合要求。” 也许是,也许不是。
DSL 从来没有编程语言的全部功能。 如果是这样,它就不再是 DSL,而是一门完整的编程语言。
但这不是 DSL 的全部意义所在吗? 没有可用的编程语言的全部功能,以便我们可以实现抽象并消除大多数错误来源? 也许是吧。 然而,大多数DSL 都是从简单的开始,然后逐渐融合了越来越多的编程语言的功能,直到事实上它变成了一个。 模板系统就是一个很好的例子。 让我们看看模板系统的标准特性以及它们如何与编程语言工具相关联:
- 替换模板中的文本:变量替换。
- 模板的重复:循环。
- 如果不满足条件,请避免打印模板:条件。
- 部分:子程序。
- Helpers :子例程(与 partials 的唯一区别是 helpers 可以访问底层编程语言并让您摆脱 DSL 紧身衣)。
这种认为 DSL 受到限制的论点是因为它同时觊觎和拒绝编程语言的力量,这与 DSL 的特性直接映射到编程语言的特性的程度成正比。 在 SQL 的情况下,这个论点很弱,因为 SQL 提供的大多数东西都与您在普通编程语言中找到的完全不同。 在光谱的另一端,我们发现几乎所有功能都使 DSL 向 BASIC 收敛的模板系统。
现在让我们退后一步,思考这三个典型的摩擦来源,用分离的概念来概括。 因为它是独立的,所以 DSL 需要位于单独的文件中; 它更难修改(甚至更难编写你自己的),并且(通常但并非总是)需要你一个一个地添加你从真正的编程语言中遗漏的特性。
无论设计得多么好,分离性都是任何 DSL 的固有问题。
我们现在转向声明性工具的第二个问题,它很普遍但不是固有的。
另一个问题:缺乏展开导致复杂性
如果我几个月前写了这篇文章,这部分会被命名为Most Declarative Tools Are #@!$#@! 复杂但我不知道为什么。 在写这篇文章的过程中,我找到了一种更好的表达方式:大多数声明性工具比它们需要的复杂得多。 我将在本节的其余部分解释原因。 为了分析工具的复杂性,我提出了一种称为复杂性差距的度量。 复杂性差距是使用工具解决给定问题与在工具打算替换的较低级别(可能是简单的命令式代码)中解决问题之间的差异。 当前一种解决方案比后者更复杂时,我们就存在复杂性差距。 更复杂,我的意思是更多的代码行,更难阅读、更难修改和更难维护的代码,但不一定同时所有这些。
请注意,我们不是将较低级别的解决方案与最好的工具进行比较,而是与没有工具进行比较。 这与“第一,不伤害”的医学原则相呼应。
具有较大复杂性差距的工具的迹象是:
- 即使您知道如何使用该工具,使用该工具编写代码需要几分钟才能以命令式术语详细描述。
- 你觉得你一直在围绕这个工具工作,而不是在使用这个工具。
- 您正在努力解决一个直接属于您正在使用的工具领域的问题,但是您找到的最佳 Stack Overflow 答案描述了一种解决方法。
- 当这个非常简单的问题可以通过某个功能(工具中不存在)来解决时,您会在库中看到一个 Github 问题,其中包含对所述功能的长时间讨论,其中穿插了+1秒。
- 一种慢性的、发痒的、渴望放弃工具并在_for-loop_中自己完成所有事情的人。
我可能在这里情绪激动,因为模板系统并不那么复杂,但是这种相对较小的复杂性差距并不是他们设计的优点,而是因为适用领域非常简单(请记住,我们只是在这里生成 HTML )。 每当将相同的方法用于更复杂的领域(例如配置管理)时,复杂性差距可能会迅速将您的项目变成泥潭。
也就是说,一个工具比它打算替换的较低级别稍微复杂一些并不一定是不可接受的; 如果该工具生成的代码更易读、更简洁、更正确,那么它是值得的。 当工具比它所替代的问题复杂几倍时,这是一个问题; 这是完全不可接受的。 Brian Kernighan 有句名言:“控制复杂性是计算机编程的本质。 ” 如果一个工具给你的项目增加了很大的复杂性,为什么还要使用它呢?
问题是,为什么一些声明性工具比它们需要的复杂得多? 我认为将其归咎于糟糕的设计是错误的。 这种笼统的解释,对这些工具的作者进行全面的人身攻击,是不公平的。 必须有一个更准确和启发性的解释。
我的论点是,任何提供高级接口来抽象较低层次的工具都必须从较低层次展开这个较高层次。 展开的概念来自克里斯托弗·亚历山大的代表作《秩序的本质》——尤其是第二卷。 总结这项具有里程碑意义的工作对软件设计的影响,这超出了本文的范围(更不用说我的理解)了。 我相信它的影响在未来几年将是巨大的。 提供展开过程的严格定义也超出了本文的范围。 我将在这里以启发式的方式使用这个概念。
展开过程是以逐步的方式创建进一步的结构而不否定现有结构的过程。 在每一步,每一个变化(或分化,用亚历山大的术语来说)都与任何先前的结构保持一致,而先前的结构只是过去变化的结晶序列。
有趣的是,Unix 是一个从低层次展开高层次的很好的例子。 在 Unix 中,操作系统的两个复杂特性,批处理作业和协程(管道),只是基本命令的扩展。 由于某些基本的设计决策,例如使一切都成为字节流,shell 是用户态程序和标准 I/O 文件,Unix 能够以最小的复杂性提供这些复杂的功能。
为了强调为什么这些是展开的优秀例子,我想引用 1979 年 Unix 的作者之一丹尼斯·里奇 (Dennis Ritchie) 的一篇论文的摘录:
在批处理作业上:
…新的过程控制方案立即使一些非常有价值的特性变得微不足道; 例如分离进程(使用
&
)和递归使用 shell 作为命令。 大多数系统必须为不同于交互式使用的文件提供某种特殊的batch job submission
工具和特殊的命令解释器。
关于协程:
Unix 管道的天才之处恰恰在于它是由以单工方式经常使用的相同命令构成的。
我认为,这种优雅和简洁来自一个展开的过程。 批处理作业和协程是从以前的结构(在用户空间 shell 中运行的命令)展开的。 我相信,由于创建 Unix 的团队的极简主义理念和有限的资源,系统逐步发展,因此,能够在不退回基本功能的情况下整合高级功能,因为没有足够的资源来不这样做。
在没有展开过程的情况下,高层将比必要的复杂得多。 换句话说,大多数声明性工具的复杂性源于这样一个事实,即它们的高层次并没有从它们打算替换的低层次展开。
如果您原谅新词,这种缺乏展开通常是有必要保护用户免受较低级别的影响。 这种强调防错(保护用户免受低级错误)的代价是巨大的复杂性差距会弄巧成拙,因为额外的复杂性会产生新的错误类别。 雪上加霜的是,这些类型的错误与问题域无关,而是与工具本身有关。 如果我们将这些错误描述为医源性,我们不会走得太远。
声明式模板工具,至少在应用于生成 HTML 视图的任务时,是高级别的典型案例,它背弃了它打算替换的低级。 为何如此? 因为生成任何非平凡的视图都需要逻辑,而模板系统,尤其是无逻辑的系统,会通过大门排除逻辑,然后通过猫门将其中的一部分偷偷带回。
注意:对于较大的复杂性差距的一个更弱的理由是,当一个工具被推销为魔术或只是工作的东西时,低级别的不透明性应该是一种资产,因为魔术工具总是应该在你不理解的情况下工作为什么或如何。 根据我的经验,一个工具声称的越神奇,它就越快将我的热情转化为挫败感。
但是关注点分离呢? 视图和逻辑不应该保持分离吗? 这里的核心错误是将业务逻辑和表示逻辑放在同一个包中。 业务逻辑在模板中当然没有位置,但表示逻辑仍然存在。 从模板中排除逻辑会将表示逻辑推送到服务器中,在那里它被笨拙地容纳。 我将这一点的清晰表述归功于 Alexei Boronine,他在本文中为它提供了一个极好的案例。
我的感觉是,大约三分之二的模板工作存在于其表示逻辑中,而另外三分之一处理一般问题,例如连接字符串、关闭标签、转义特殊字符等。 这是生成 HTML 视图的两面低级性质。 模板系统适当地处理后半部分,但它们在前半部分处理得不好。 无逻辑模板完全拒绝了这个问题,迫使你笨拙地解决它。 其他模板系统受到影响,因为它们确实需要提供一种非平凡的编程语言,以便它们的用户可以实际编写表示逻辑。
总结; 声明性模板工具受到影响,因为:
- 如果他们要从他们的问题领域展开,他们将不得不提供生成逻辑模式的方法;
- 提供逻辑的 DSL 并不是真正的 DSL,而是一种编程语言。 请注意,其他领域,如配置管理,也缺乏“展开”。
我想用一个逻辑上与本文主线脱节的论点来结束批评,但与它的情感核心产生了深刻的共鸣:我们学习的时间有限。 人生苦短,除此之外,我们还需要努力。 面对我们的局限,我们需要花时间学习有用且经得起时间的东西,即使面对快速变化的技术。 这就是为什么我建议您使用的工具不仅提供解决方案,而且实际上在其自身的适用范围内提供了一个亮点。 RDB 教你数据,Unix 教你操作系统概念,但由于工具不令人满意,无法展开,我一直觉得我在学习次优解决方案的复杂性,同时对问题的本质一无所知它打算解决。
我建议您考虑的启发式方法是,重视能够阐明其问题领域的工具,而不是那些在声称的特征背后掩盖其问题领域的工具。
双子方法
为了克服我在这里提出的声明式编程的两个问题,我提出了一种双方法:
- 使用数据结构领域特定语言 (dsDSL) 来克服分离性。
- 创建一个从较低层次展开的高层次,以克服复杂性差距。
数字DSL
数据结构 DSL (dsDSL) 是使用编程语言的数据结构构建的 DSL。 其核心思想是使用您可用的基本数据结构,例如字符串、数字、数组、对象和函数,并将它们组合起来以创建抽象来处理特定的域。
我们希望保持声明结构或动作的能力(高级),而不必指定实现这些构造的模式(低级)。 我们希望克服 DSL 和我们的编程语言之间的分离,以便我们可以在需要时自由地使用编程语言的全部功能。 这不仅是可能的,而且通过 dsDSL 很简单。
如果你在一年前问我,我会认为 dsDSL 的概念很新颖,然后有一天,我意识到 JSON 本身就是这种方法的完美示例! 已解析的 JSON 对象由以声明方式表示数据条目的数据结构组成,以便获得 DSL 的优势,同时也使其易于在编程语言中解析和处理。 (可能还有其他 dsDSL,但到目前为止我还没有遇到过。如果您知道其中一个,我将非常感谢您在评论部分提及它。)
与 JSON 一样,dsDSL 具有以下属性:
- 它由一组非常小的函数组成:JSON 有两个主要函数,
parse
和stringify
。 - 它的函数最常接收复杂的递归参数:解析的 JSON 是一个数组或对象,其中通常包含更多的数组和对象。
- 这些函数的输入符合非常特定的形式:JSON 有一个明确且严格强制执行的验证模式来区分有效结构和无效结构。
- 这些函数的输入和输出都可以由编程语言包含和生成,而无需单独的语法。
但是 dsDSL 在很多方面都超越了 JSON。 让我们创建一个 dsDSL 用于使用 Javascript 生成 HTML。 稍后我会谈到这种方法是否可以扩展到其他语言的问题(剧透:它肯定可以在 Ruby 和 Python 中完成,但在 C 中可能不行)。
HTML 是一种标记语言,由尖括号( <
和>
)分隔的tags
组成。 这些标签可能有可选的属性和内容。 属性只是键/值属性的列表,内容可以是文本或其他标签。 对于任何给定的标签,属性和内容都是可选的。 我有点简化,但它是准确的。
在 dsDSL 中表示 HTML 标记的一种直接方法是使用具有三个元素的数组: - 标记:字符串。 - 属性:一个对象(普通的键/值类型)或undefined
的(如果不需要属性)。 - 内容:字符串(文本)、数组(另一个标签)或undefined
(如果没有内容)。
例如, <a href="views">Index</a>
可以写成['a', {href: 'views'}, 'Index']
。
如果我们想将此锚元素嵌入到带有类links
的div
中,我们可以这样写: ['div', {class: 'links'}, ['a', {href: 'views'}, 'Index']]
.
要在同一级别列出多个 html 标签,我们可以将它们包装在一个数组中:
[ ['h1', 'Hello!'], ['a', {href: 'views'}, 'Index'] ]
相同的原则可以应用于在标签内创建多个标签:
['body', [ ['h1', 'Hello!'], ['a', {href: 'views'}, 'Index'] ]]
当然,如果我们不从中生成 HTML,这个 dsDSL 不会让我们走得太远。 我们需要一个generate
函数,它将获取我们的 dsDSL 并生成一个带有 HTML 的字符串。 因此,如果我们运行generate (['a', {href: 'views'}, 'Index'])
,我们将得到字符串<a href="views">Index</a>
。
任何 DSL 背后的想法是指定一些具有特定结构的构造,然后将其传递给函数。 在这种情况下,构成 dsDSL 的结构就是这个数组,它有 1 到 3 个元素; 这些数组具有特定的结构。 如果generate
彻底验证了它的输入(彻底验证输入既容易又重要,因为这些验证规则是 DSL 语法的精确模拟),它会准确地告诉你输入出错的地方。 一段时间后,您将开始认识到 dsDSL 中有效结构的区别,并且该结构将高度暗示它生成的底层内容。
现在,与 DSL 相对的 dsDSL 的优点是什么?
- dsDSL 是代码中不可或缺的一部分。 它可以减少行数、文件数和总体开销的减少。
- dsDSL易于解析(因此更易于实现和修改)。 解析只是遍历数组或对象的元素。 同样,dsDSL 相对容易设计,因为您可以坚持使用您的编程语言的语法(每个人都讨厌,但至少他们已经知道),而不是创建新的语法(每个人都会讨厌)。
- dsDSL 具有编程语言的所有功能。 这意味着如果使用得当,dsDSL 具有高级工具和低级工具的优点。
现在,最后一个主张是一个强有力的主张,所以我将在本节的其余部分中支持它。 我所说的适当就业是什么意思? 为了看到这一点,让我们考虑一个示例,在该示例中,我们要构造一个表来显示来自名为DATA
的数组的信息。
var DATA = [ {id: 1, description: 'Product 1', price: 20, onSale: true, categories: ['a']}, {id: 2, description: 'Product 2', price: 60, onSale: false, categories: ['b']}, {id: 3, description: 'Product 3', price: 120, onSale: false, categories: ['a', 'c']}, {id: 4, description: 'Product 4', price: 45, onSale: true, categories: ['a', 'b']} ]
在实际应用程序中, DATA
将从数据库查询中动态生成。
此外,我们有一个FILTER
变量,在初始化时,它将是一个包含我们要显示的类别的数组。
我们希望我们的表:
- 显示表格标题。
- 对于每个产品,显示以下字段:描述、价格和类别。
- 不要打印
id
字段,而是将其添加为每一行的id
属性。 替代版本:为每个tr
元素添加一个id
属性。 - 如果产品正在销售,请放置一个类
onSale
。 - 按降价对产品进行排序。
- 按类别过滤某些产品。 如果
FILTER
是一个空数组,我们将显示所有产品。 否则,我们将仅显示产品类别包含在FILTER
中的产品。
我们可以在大约 20 行代码中创建符合此要求的表示逻辑:
function drawTable (DATA, FILTER) { var printableFields = ['description', 'price', 'categories']; DATA.sort (function (a, b) {return a.price - b.price}); return ['table', [ ['tr', dale.do (printableFields, function (field) { return ['th', field]; })], dale.do (DATA, function (product) { var matches = (! FILTER || FILTER.length === 0) || dale.stop (product.categories, true, function (category) { return FILTER.indexOf (category) !== -1; }); return matches === false ? [] : ['tr', { id: product.id, class: product.onSale ? 'onsale' : undefined }, dale.do (printableFields, function (field) { return ['td', product [field]]; })]; }) ]]; }
我承认这不是一个简单的示例,但是,它代表了持久存储(也称为 CRUD)的四个基本功能的相当简单的视图。 任何不平凡的 Web 应用程序都会有比这更复杂的视图。

现在让我们看看这段代码在做什么。 首先,它定义了一个函数drawTable
来包含绘制产品表的表示逻辑。 该函数接收DATA
和FILTER
作为参数,因此可以用于不同的数据集和过滤器。 drawTable
实现了partial 和 helper 的双重角色。
var drawTable = function (DATA, FILTER) {
内部变量printableFields
是唯一需要指定哪些字段是可打印字段的地方,以避免在需求变化时出现重复和不一致。
var printableFields = ['description', 'price', 'categories'];
然后我们根据其产品的价格对DATA
进行排序。 请注意,不同的和更复杂的排序标准将很容易实现,因为我们拥有整个编程语言可供使用。
DATA.sort (function (a, b) {return a.price - b.price});
这里我们返回一个对象字面量; 一个数组,其中包含table
作为其第一个元素,其内容作为第二个元素。 这是我们要创建的<table>
的 dsDSL 表示。
return ['table', [
我们现在创建一个带有表头的行。 为了创建它的内容,我们使用 dale.do,它是一个类似于 Array.map 的函数,但也适用于对象。 我们将迭代printableFields
并为它们中的每一个生成表头:
['tr', dale.do (printableFields, function (field) { return ['th', field]; })],
请注意,我们刚刚实现了迭代,这是 HTML 生成的主力,我们不需要任何 DSL 构造; 我们只需要一个函数来迭代数据结构并返回 dsDSL。 类似的本机或用户实现的功能也可以做到这一点。
现在遍历DATA
中包含的产品。
dale.do (DATA, function (product) {
我们检查该产品是否被FILTER
排除在外。 如果FILTER
为空,我们将打印产品。 如果FILTER
不为空,我们将遍历产品的类别,直到找到包含在FILTER
中的类别。 我们使用 dale.stop 执行此操作。
var matches = (! FILTER || FILTER.length === 0) || dale.stop (product.categories, true, function (category) { return FILTER.indexOf (category) !== -1; });
注意条件的复杂性; 它完全根据我们的要求量身定制,我们完全可以自由地表达它,因为我们使用的是编程语言而不是 DSL。
如果matches
是false
,我们返回一个空数组(所以我们不打印这个产品)。 否则,我们返回一个<tr>
及其正确的 id 和类,然后我们遍历printableFields
以打印字段。
return matches === false ? [] : ['tr', { id: product.id, class: product.onSale ? 'onsale' : undefined }, dale.do (printableFields, function (field) { return ['td', product [field]];
当然,我们会关闭我们打开的所有内容。 语法不好玩吗?
})]; }) ]]; }
现在,我们如何将这张表融入更广泛的背景中? 我们编写了一个名为drawAll
的函数,它将调用所有生成视图的函数。 除了drawTable
,我们可能还有drawHeader
、 drawFooter
和其他类似的函数,它们都会返回 dsDSLs 。
var drawAll = function () { return generate ([ drawHeader (), drawTable (DATA, FILTER), drawFooter () ]); }
如果你不喜欢上面代码的样子,我说什么都不能说服你。 这是最好的 dsDSL 。 你不妨停止阅读这篇文章(并放弃一个刻薄的评论,因为如果你做到了这一点,你已经获得了这样做的权利!)。 But seriously, if the code above doesn't strike you as elegant, nothing else in this article will.
For those who are still with me, I would like to go back to the main claim of this section, which is that a dsDSL has the advantages of both the high and the low level :
- The advantage of the low level resides in writing code whenever we want, getting out of the straightjacket of the DSL.
- The advantage of the high level resides in using literals that represent what we want to declare and letting the functions of the tool convert that into the desired end state (in this case, a string with HTML).
But how is this truly different from purely imperative code? I think ultimately the elegance of the dsDSL approach boils down to the fact that code written in this way mostly consists of expressions, instead of statements. More precisely, code that uses a dsDSL is almost entirely composed of:
- Literals that map to lower level structures.
- Function invocations or lambdas within those literal structures that return structures of the same kind.
Code that consists mostly of expressions and which encapsulate most statements within functions is extremely succinct because all patterns of repetition can be easily abstracted. You can write arbitrary code as long as that code returns a literal that conforms to a very specific, non-arbitrary form.
A further characteristic of dsDSLs (which we don't have time to explore here) is the possibility of using types to increase the richness and succinctness of the literal structures. I will expound on this issue on a future article.
Might it be possible to create dsDSLs beyond Javascript, the One True Language? I think that it is, indeed, possible, as long as the language supports:
- Literals for: arrays, objects (associative arrays), function invocations, and lambdas.
- Runtime type detection
- Polymorphism and dynamic return types
I think this means that dsDSLs are tenable in any modern dynamic language (ie: Ruby, Python, Perl, PHP), but probably not in C or Java.
Walk, Then Slide: How To Unfold The High From The Low
In this section I will attempt to show a way for unfolding a high level tool from its domain. In a nutshell, the approach consists of the following steps
- Take two to four problems that are representative instances of a problem domain. These problems should be real. Unfolding the high level from the low one is a problem of induction, so you need real data to come up with representative solutions.
- Solve the problems with no tool in the most straightforward way possible.
- Stand back, take a good look at your solutions, and notice the common patterns among them.
- Find the patterns of representation (high level).
- Find the patterns of generation (low level).
- Solve the same problems with your high level layer and verify that the solutions are indeed correct.
- If you feel that you can easily represent all the problems with your patterns of representation, and the generation patterns for each of these instances produce correct implementations, you're done. Otherwise, go back to the drawing board.
- If new problems appear, solve them with the tool and modify it accordingly.
- The tool should converge asymptotically to a finished state, no matter how many problems it solves. In other words, the complexity of the tool should remain constant, rather than growing with the amount of problems it solves.
Now, what the hell are patterns of representation and patterns of generation ? 我很高兴你问。 The patterns of representation are the patterns in which you should be able to express a problem that belongs to the domain that concerns your tool. It is an alphabet of structures that allows you to write any pattern you might wish to express within its domain of applicability. In a DSL, these would be the production rules. Let's go back to our dsDSL for generating HTML.
The patterns of representation for HTML are the following:
- A single tag:
['TAG']
- A single tag with attributes:
['TAG', {attribute1: value1, attribute2: value2, ...}]
- A single tag with contents:
['TAG', 'CONTENTS']
- A single tag with both attributes and contents:
['TAG', {attribute1: value1, ...}, 'CONTENTS']
- A single tag with another tag inside:
['TAG1', ['TAG2', ...]]
- A group of tags (standalone or inside another tag):
[['TAG1', ...], ['TAG2', ...]]
- Depending on a condition, place a tag or no tag:
condition ? ['TAG', ...] : []
/ Depending on a condition, place an attribute or no attribute:['TAG', {class: condition ? 'someClass': undefined}, ...]
These instances can be represented with the dsDSL notation we determined in the previous section. And this is all you need to represent any HTML you might need. More sophisticated patterns, such as conditional iteration through an object to generate a table, may be implemented with functions that return the patterns of representation above, and these patterns map directly to HTML tags.
If the patterns of representation are the structures you use to express what you want, the patterns of generation are the structures your tool will use to convert patterns of representation into the lower level structures. For HTML, these are the following:
- Validate the input (this is actually is an universal pattern of generation).
- Open and close tags (but not the void tags, like
<input>
, which are self-closing). - Place attributes and contents, escaping special characters (but not the contents of the
<style>
and<script>
tags).
Believe it or not, these are the patterns you need to create an unfolding dsDSL layer that generates HTML. Similar patterns can be found for generating CSS. In fact, lith does both, in ~250 lines of code.
One last question remains to be answered: What do I mean by walk, then slide ? When we deal with a problem domain, we want to use a tool that delivers us from the nasty details of that domain. In other words, we want to sweep the low level under the rug, the faster the better. The walk, then slide approach proposes exactly the opposite: spend some time on the low level. Embrace its quirks, and understand which are essential and which can be avoided in the face of a set of real, varied, and useful problems.
After walking in the low level for some time and solving useful problems, you will have a sufficiently deep understanding of their domain. The patterns of representation and generation will then arise naturally; they are wholly derived from the nature of the problem they intend to solve. You can then write code that employs them. If they work, you will be able to slide through problems where you recently had to walk through them. Sliding means many things; it implies speed, precision and lack of friction. Maybe more importantly, this quality can be felt; when solving problems with this tool, do you feel like you're walking through the problem, or do you feel that you're sliding through it?
Maybe the most important thing about an unfolded tool is not the fact that it frees us from having to deal with the low level. Rather, by capturing the empiric patterns of repetition in the low level, a good high level tool allows us to understand fully the domain of applicability.
An unfolded tool will not just solve a problem - it will enlighten you about the problem's structure.
So, don't run away from a worthy problem. First walk around it, then slide through it.