面向 DSL 设计 API 是否会形成语义的滥用?

  • 赵劼

2009 年 11 月 30 日

话题:.NETC#语言 & 开发架构

本月初,博客园的老赵在其博客上发表了一篇文章,谈到了一种在他眼中兼顾性能和可读性的 DSL,以此在 ASP.NET MVC 应用程序中构造 URL。但也有人认为,这种构造方式违反了语言元素原本的语义,让人难以从签名中快速看出它的使用方法,因此是一种不可取的方式。

在使用 ASP.NET MVC 框架构建 Web 应用程序时,一个很常见的需求便是构造面向某个特定 Action 方法及参数的 URL。老赵原本在他的MvcPatch项目(一个基于 ASP.NET MVC 进行改造以提高生产力的框架)中提供了一种基于表达式树的构造方式,但经过测试之后发现这种方式有很大的性能问题。因此老赵后来又提出一种流畅接口(Fluent Interface):

public static class UrlHelperExtensions
{
    public static ActionOf<TController> Of<TController>(this UrlHelper helper) where TController : new()
    {
        return new ActionOf<TController>(helper);
    }
}
 
public class ActionOf<TController>
{
    public string Action<T1, T2>(Func<TController, Func<T1, T2, ActionResult>> action, T1 arg1, T2 arg2) {...}
}

他认为,这是一种用于构造 URL 的 DSL,可以这样使用:

Url.Of<HomeController>().Action(c => c.Post, blog, post)

以上这行代码表示的含义是“URL of HomeController’s action ‘Post’ with parameter ‘blog’ & post.”,它将会生成一个 URL。针对这个 URL 的请求便会被转化为 HomeController.Post 方法的调用,并且提供 DSL 中所指定的 blog 和 post 的值作为参数。老赵认为,这个做法从性能上比原本基于表达式树的构造方式要高出许多,并且充分利用 C# 编译器的类型推导能力,拥有较好的可读性。而更关键的是,这种做法可以在编译期对 URL 构造进行静态检查。

不过在随后的评论中,网友装配脑袋提出,这个 API 不适合 VB,不能算是真正优美的解决方案,而且更重要的是,语言元素应该用做原本职责所在的事情(即“委托”应该用于方法调用):

这种 Url.Of<HomeController>().Action(c => c.Post, blog, post) 的语法,真正给别人使用的时候,没有人会从方法签名或类的定义中快速看出它的使用方法。

另外你也应该能从我的代码里看出,在 VB 里,从方法名获得委托需要 AddressOf 运算符,你的代码就会变成 Url.Of(Of HomeController).Action(Function(c)AddressOf c.Post, blog, post)。这其实代表了委托和方法语义上的差异。用这种语法,将造成滥用语义的倾向。

我觉得这条路彻底走歪了。从方法名获得委托,仅仅在用户明确知道是委托的上下文才有意义。你们不能滥用这个语法。

老赵则解释道:

其实我觉得 API 还是针对特定语言来说可能比较合适,否则的话,就算以前的 Lambda 表达式写法:Url.Action<HomeController>(c => c.Post(blog, post)) 放在 VB 里写起来还是麻烦。但是这种方式,其实微软本身也很推崇,在 ASP.NET MVC 2 里也有类似的 API(不过不是做同样的事情)。

如果思考“非 C# 语言”语法的话,很多东西就不好办了,比如 Moq。还比如 F# 的 API 也不会考虑 C# 的使用方式,语言特性不同么……我现在就好比是在为 80% 占有率的语言设计 API,而只能忽视 20% 了,这是准备中的,除非有更好的设计,否则只能先满足这 80% 了……

另一网友Ivony则认为这似乎也不能算是走歪了:

委托不仅仅是方法的调用包装(透过委托可以调用方法),同样也可以是方法的信息包装(透过委托找到方法)。我们一般用前者,但不见得后者就歪了呀。

老赵又作了补充:

其实,是否可以从方法的签名中得出使用方式,这个我也不太在意。尤其是在视图中,我一般都把这些东西当作是 DSL 来看待,所以我会设计出“url of HomeController's action Post with...”这种语义的 API。而使用的时候只要记住“用法”就行了,不关心“签名”究竟如何,就像使用不动点组合子来生成递归形式的 HTML,从签名也实在看不出来。

的确,如果可以从签名看出来那自然最为理想,但是在 DSL 面前,这点还是让步吧,比如在FsTest里:

"foo" |> should equal "foo"
true |> should be True

这种东西,看 should 函数,be 函数,not 函数(的签名)……都是搞不懂该怎么用的,但就是为了让别人看明白写出来的代码,让别人知道该怎么写。

装配脑袋也进一步阐述了他眼中这种 API 的坏处:

用户不会想到这里是要写 c => c.Detail,你没法限制用户第一层必须使用 Lambda 表达式,所以用户不可能思考到诸如 Func<..,Func<>> 是做这个目的的。

至于 DSL,我觉得大部分尝试真的是玩具。论坛上好多人也热衷于折腾。但如果你想让你的类库有实用性,做 API 的时候正的必须要好好考虑所用语法的语义。

用奇技淫巧来写代码的时代已经过去了,C++ 的同僚们都开始清醒了……

Ivony 认为,这种 API 虽然有问题,但其实也并非完全不可接受:

老实说我是没觉得这样优美的……不过我觉得这样也不算是特别的那啥。我明白你的意思了,如果这个东西作为 API 提供的确是不太合适的。的确是不能“自然”的直接悟出使用方式。

好吧,我的确没用 API 的高度去约束它。但实际上现在.NET 的一些 API 的风格也在变,也有一些不是那么严谨的开始出现。我觉得这个度还是很难去把握。也同意你的观点。

接着,对于老赵眼中“较好的可读性”,装配脑袋也有自己的看法:

读着很通顺并不等于很容易地理解其行为。用户一般视方法为动作,每一步应该有每一步的语义。

Url.Of<???>... 这一步你想让用户看出什么语义呢?将 Url 转化为??? 类型吗?如果想让这个稍微有点语义的话,我看只能设计成这样:Url.CreateForType<???>....

对此,老赵解释道:

我是看整句的:“Url of HomeController's action Post with paramters blog, post...” 其中只有“'s”和“with...”是我补充上去的,这也是我选择用 Of 作为方法名的原因。就像 FsTest 中:true |> should be True,它是作为一句话看代,而不是认为 should 作了一件事情,be 又做了另一件事情。

还有比如 Fluent NHibernate 中:Reference<UserDetail>().Not.LazyLoad(),我觉得这也是在从整句进行考虑,而不是一个方法便是一个步骤。

最后,Ivony 也系统阐述了他对于 API 设计的思路:

嗯,其实我觉得应该说只是有分歧没矛盾。

你要我说,我欣赏老赵这样的语法么?老实说我不欣赏,虽然部分方法虽然是我提出的,但这种语法要我接受我还需要一些时间。我在考虑这个方法的时候,也过分的追求 Action(c.Post)( blog, post ) 而不是 Action( c.Post, blog, post ) 的形式,因为我觉得前者才像是函数调用,后者看起来就不像是函数调用了,这样会造成一些阅读障碍。对前种形式过分的追求还使得我竟然没发现后种形式是成立的(真是个低级错误)。

至于老赵所用的这样两个方法的配合,你要我说,我真的说现阶段在 API 我还是不能完全认同。……但我也觉得,虽然现在还难以接受,却也真的不是有什么强有力的根据说这种方式有多么不可取。诚然,这种方法如果按照传统的从方法签名参数含义来阐述,基本上 100 个人 99 个不知道怎么用。不给 Demo 几乎没办法正确的理解使用方式。

但是尽管我觉得别扭,却也不得不承认这样的方案不错。我并没有放弃语义更明晰的方案的探索和寻找,却也真找不出什么特别有份量的理由否决这种方式。

……

所以这种事情是个见仁见智的问题,接受是需要一些时间的,老实说我是比较传统的程序员。我坦白第一次看到 XElement 的构造函数的时候,我都觉得不是很舒服。这种事情怎么说呢,可以说现在双方的观点我都认同,所以说我觉得没有矛盾,只有分歧。从这个角度来说,A 是合理的,从另一个角度来说,B 是合理的。不存在一个角度 A 和 B 存在非此即彼的关系。

API 的设计是个永远的话题。尽管语言不断增强,但对于人们对于优秀 API 依旧进行着不断地追求。对于这个话题,您能给出自己的见解吗?

.NETC#语言 & 开发架构