類型是了解編程語言的重要一環(huán)。就算是你喜歡動態(tài)類型語言,為了想實(shí)現(xiàn)一個(gè)靠譜的東西,那也必須了解類型。舉個(gè)簡單的例子,我們都知道+和-是對稱的——當(dāng)然這只是我們的愿望了,在javascript里面,"1"+2和"1"-2就不是一回事。這就是由于不了解類型的操作而犯下的一些滑稽的錯(cuò)誤。什么,你覺得因?yàn)?1"的類型是string所以"1"+2就應(yīng)該是"12"?啐!"1"的類型是(string | number),這才是正確的做法。
了解編程語言的基本原理并不意味著你一定要成為一名編譯器的前端,正如同學(xué)習(xí)Haskell可以讓你的C++寫得更好一樣,如果你知道怎么設(shè)計(jì)一門語言,那遇到語言里面的坑,你十有八九可以當(dāng)場看到,不會跳進(jìn)去。當(dāng)然了,了解編程語言的前提是你是一個(gè)優(yōu)秀的程序員,至少要寫程序,對吧。于是我這里推薦幾門語言是在此之前要熟悉的。編程語言有好多種,每一種都有其代表作,為了開開眼界,知道編程語言可以設(shè)計(jì)成什么樣子,你至少應(yīng)該學(xué)會:
- C++
- C#
- F#
- Haskell
- Ruby
- Prolog
其實(shí)這一點(diǎn)也不多,因?yàn)橹皇菍W(xué)會而已,知道那些概念就好了,并不需要你成為一個(gè)精通xx語言的人。那為了了解類型你應(yīng)該學(xué)會什么呢?沒錯(cuò)——就是C++了!很多人可能不明白,為什么長得這么難看的C++竟然有這么重要的作用呢?其實(shí)如果詳細(xì)了解了程序設(shè)計(jì)語言的基本原理之后,你會發(fā)現(xiàn),C++在除了兼容那個(gè)可憐的C語言之外的那些東西,是設(shè)計(jì)的非常科學(xué)的。當(dāng)然現(xiàn)在講這些還太早,今天的重點(diǎn)是類型。
如果你們?nèi)タ聪嚓P(guān)的書籍或者論文的話,你們會發(fā)現(xiàn)類型這個(gè)領(lǐng)域里面有相當(dāng)多的莫名其妙的類型系統(tǒng),或者說名詞。對于第一次了解這個(gè)方面的人來說,熟練掌握Haskell和C++是很有用的,因?yàn)镠askell可以讓你真正明白類型在程序里面的重要做喲的同時(shí)。幾乎所有流行的東西都可以在C++里面找到,譬如說:
- 面向?qū)ο?#8594;class
- polymorphic type→template
- intersection type→union / 函數(shù)重載
- dependent type→帶數(shù)字的模板類型
- System F→在泛型的lambda表達(dá)式里面使用decltype(看下面的例子)
- sub typing的規(guī)則→泛型lambda表達(dá)式到函數(shù)指針的隱式類型轉(zhuǎn)換
等等等等,因有盡有,取之不盡,用之不竭。你先別批判C++,覺得他東西多所以糟糕。事實(shí)是,只要編譯器不用你寫,那一門語言是不可能通過拿掉feature來使它對你來說變得更牛逼的。不知道為什么有那么多人不了解這件事情,需要重新去念一念《形式邏輯》,早日爭取做一個(gè)靠譜的人。
泛型lambda表達(dá)式是C++14(沒錯(cuò),是14,已經(jīng)基本敲定了)的內(nèi)容,應(yīng)該會有很多人不知道,我在這里簡單地講一下。譬如說要寫一個(gè)lambda表達(dá)式來計(jì)算一個(gè)容器里所有東西的和,但是你卻不知道容器和容器里面裝的東西是什么。當(dāng)然這種情況也不多,但是有可能你需要把這個(gè)lambda表達(dá)使用在很多地方,對吧,特別是你#include <algorithm>用了里面超好用的函數(shù)之后,這種情況就變得常見了。于是這個(gè)東西可以這么寫:
auto lambda = [](const auto& xs) { decltype(*xs.begin()) sum = 0; for(auto x : xs) { sum += x; } return sum; };
于是你就可以這么用了:
vector<int> a = { ... }; list<float> b = { ... }; deque<double> c = { ... }; int sumA = lambda(a); float sumB = lambda(b); double sumC = lambda(c);
然后還可以應(yīng)用sub typing的規(guī)則把這個(gè)lambda表達(dá)式轉(zhuǎn)成一個(gè)函數(shù)指針。C++里面所有中括號不寫東西的lambda表達(dá)式都可以被轉(zhuǎn)成一個(gè)函數(shù)指針的,因?yàn)樗緛砭涂梢援?dāng)成一個(gè)普通函數(shù),只是你為了讓業(yè)務(wù)邏輯更緊湊,選擇把這個(gè)東西寫在了你的代碼里面而已:
doube(*summer)(const vector<double>&); summer = lambda;
只要搞明白了C++之后,那些花里胡俏的類型系統(tǒng)的論文的概念并不難理解。他們深入研究了各種類型系統(tǒng)的主要原因是要做系統(tǒng)驗(yàn)證,證明這個(gè)證明那個(gè)。其實(shí)編譯器的類型檢查部分也可以當(dāng)成是一個(gè)系統(tǒng)驗(yàn)證的程序,他要檢查你的程序是不是有問題,于是首先檢查系統(tǒng)。不過可惜的是,除了Haskell以外的其他程序語言,就算你過了類型系統(tǒng)檢查,也不見得你的程序就是對的。當(dāng)然了,對于像javascript這種動態(tài)類型就罷了還那么多坑(ruby在這里就做得很好)的語言,得通過大量的自動化測試來保證。沒有類型的幫助,要寫出同等質(zhì)量的程序,需要花的時(shí)間要更多。什么?你不關(guān)心質(zhì)量?你不要當(dāng)程序員了!是因?yàn)槔习宕叩锰o?我們Microsoft最近有招聘了,快來吧,可以慢慢寫程序!
不過正因?yàn)榫幾g器會檢查類型,所以我們其實(shí)可以把一個(gè)程序用類型武裝起來,使得錯(cuò)誤的寫法會變成錯(cuò)誤的語法被檢查出來了。這種事情在C++里面做尤為方便,因?yàn)樗С謉ependent type——好吧,就是可以在模板類型里面放一些不是類型的東西。我來舉一個(gè)正常人都熟練掌握的例子——單位。
一、類型檢查(type rich programming)
我們都知道物理的三大基本單位是米、秒和千克,其它東西都可以從這些單位拼出來(大概是吧,我忘記了)。譬如說我們通過F=ma可以知道力的單位,通過W=FS可以知道功的單位,等等。然后我們發(fā)現(xiàn),單位之間的關(guān)系都是乘法的關(guān)系,每個(gè)單位還帶有自己的冪。只要弄清楚了這一點(diǎn),那事情就很好做了。現(xiàn)在讓我們來用C++定義單位:
template<int m, int s, int kg> struct unit { double value; unit():value(0){} unit(double _value):value(_value){} };
好了,現(xiàn)在我們要通過類型系統(tǒng)來實(shí)現(xiàn)幾個(gè)操作的約束。對于乘除法我們要自動計(jì)算出單位的同時(shí),加減法必須在相同的單位上才能做。其實(shí)這樣做還不夠完備,因?yàn)閷τ谌魏蔚膯挝粁來講,他們的差單位Δx還有一些額外的規(guī)則,就像C#的DateTime和TimeSpan一樣。不過這里先不管了,我們來做出加減乘除幾個(gè)操作:
template<int m, int s, int kg> unit<m, s, kg> operator+(unit<m, s, kg> a, unit<m, s, kg> b) { return a.value + b.value; } template<int m, int s, int kg> unit<m, s, kg> operator-(unit<m, s, kg> a, unit<m, s, kg> b) { return a.value - b.value; } template<int m, int s, int kg> unit<m, s, kg> operator+(unit<m, s, kg> a) { return a.value; } template<int m, int s, int kg> unit<m, s, kg> operator-(unit<m, s, kg> a) { return -a.value; } template<int m1, int s1, int kg1, int m2, int s2, int kg2> unit<m1+m2, s1+s2, kg1+kg2>operator*(unit<m1, s1, kg1> a, unit<m2, s2, kg2> b) { return a.value * b.value; } template<int m1, int s1, int kg1, int m2, int s2, int kg2> unit<m1-m2, s1-s2, kg1-kg2>operator/(unit<m1, s1, kg1> a, unit<m2, s2, kg2> b) { return a.value / b.value; }
但是這個(gè)其實(shí)還不夠,我們還需要帶單位的值乘以或除以一個(gè)系數(shù)的代碼。為什么不能加減呢?因?yàn)椴煌瑔挝坏臇|西本來就不能加減。系數(shù)其實(shí)是可以描寫成unit<0, 0, 0>的,但是為了讓代碼更緊湊,于是多定義了下面的四個(gè)函數(shù):
template<int m, int s, int kg> unit<m, s, kg> operator*(double v, unit<m, s, kg> a) { return v * a.value; } template<int m, int s, int kg> unit<m, s, kg> operator*(unit<m, s, kg> a, double v) { return a.value * v; } template<int m, int s, int kg> unit<m, s, kg> operator/(double v, unit<m, s, kg> a) { return v / a.value; } template<int m, int s, int kg> unit<m, s, kg> operator/(unit<m, s, kg> a, double v) { return a.value / v; }
我們已經(jīng)用dependent type之間的變化來描述了帶單位的量的加減乘除的規(guī)則。這看起來好像很復(fù)雜,但是一旦我們加入了下面的新的函數(shù),一切將變得簡單明了:
constexpr unit<1, 0, 0> operator""_meter(double value) { return value; } constexpr unit<0, 1, 0> operator""_second(double value) { return value; } constexpr unit<0, 0, 1> operator""_kilogram(double value) { return value; } constexpr unit<1, -2,1> operator""_N(double value) // 牛不知道怎么寫-_- { return value; } constexpr unit<2, -2,1> operator""_J(double value) // 焦耳也不知道怎么寫-_- { return value; }
然后我們就可以用來寫一些神奇的代碼了:
auto m = 16_kilogram; // unit<0, 0, 1>(16) auto s = 3_meter; // unit<1, 0, 0>(3) auto t = 2_second; // unit<0, 1, 0>(2) auto a = s / (t*t); // unit<1, -2, 0>(3/4) auto F = m * a; // unit<1, -2, 1>(12)
下面的代碼雖然也神奇,但因?yàn)檫`反了物理定律,所以C++編譯器決定不讓他編譯通過:
auto W = F * s; // unit<2, -2, 1>(36) auto x = F + W; // bang!
這樣你還怕你在物理引擎里面東西倒騰來倒騰去然后公式手抖寫錯(cuò)了嗎?類似的錯(cuò)誤是不可能發(fā)生的!除非系數(shù)被你弄錯(cuò)了……如果沒有unit,要用原始的方法寫出來:
double m = 16; double s = 3; double t = 2; double a = s / (t*t); double F = m * a; double W = F * s; double x = F + W; //????
時(shí)間過得久了以后,根本不知道是什么意思了。所以為了解決這個(gè)問題,我們得用應(yīng)用匈牙利命名法(這個(gè)不是那個(gè)臭名昭著的你們熟悉的傻逼(系統(tǒng))匈牙利命名法)。我舉個(gè)例子:
string dogName = "kula"; Person person; person.name = dogName;
這個(gè)代碼大家一看就知道不對對吧,這就是應(yīng)用匈牙利命名法了。我們通過給名字一個(gè)單位——狗的——來讓person.name = dogName;這句話顯得很滑稽,從而避免低級錯(cuò)誤的發(fā)生。上面的unit就更進(jìn)一步了,把這個(gè)東西帶進(jìn)了類型系統(tǒng)里面,就算寫出來不滑稽,編譯器都會告訴你,錯(cuò)誤的東西就是錯(cuò)誤的。
然后大家可能會問,用unit這么寫程序的性能會不會大打折扣呀?如今已經(jīng)是2013年了,靠譜的C++編譯器編譯出來的代碼,跟你直接用幾個(gè)double倒騰來倒騰去的代碼其實(shí)是一樣的。C++比起其他語言的抽象的好處就是,就算你要用來做高性能的程序,也不怕因?yàn)槌橄蠖鴨适阅堋.?dāng)然如果你使用了面向?qū)ο蟮募夹g(shù),那就另當(dāng)別論了。
注,上面這段話我寫完之后貼到了粉絲群里面,然后九姑娘跟我講了很多量綱分析的故事,然后升級到航空領(lǐng)域的check list,最后講到了醫(yī)院把這一技術(shù)引進(jìn)了之后有效地阻止了手術(shù)弄錯(cuò)人等嚴(yán)重事故。那些特別靠譜的程序還得用C++來寫,譬如說洛克希德馬丁的戰(zhàn)斗機(jī),NASA的衛(wèi)星什么的。
人的精力是有限的,需要一些錯(cuò)誤規(guī)避來防止引進(jìn)低級的錯(cuò)誤或者負(fù)擔(dān),保留精力解決最核心的問題。很多軟件都是這樣的。譬如說超容易配置的MSBuild、用起來巨爽無比的Visual Studio,出了問題反正用正版安裝程序點(diǎn)一下repair就可以恢復(fù)的windows,給我們帶來的好處就是——保留精力解決最核心的問題。編程語言也是如此,類型系統(tǒng)也是如此,人類發(fā)明出的所有東西,都是為了讓你可以把更多的精力放在更加核心的問題上,更少的精力放在周邊的問題上。
但是類型到處都出現(xiàn)其實(shí)也會讓我們程序?qū)懫饋砗軣┑模袁F(xiàn)代的語言都有第二個(gè)功能,就是類型推導(dǎo)了。
二、類型推導(dǎo)
這里講的類型推導(dǎo)可不是Go語言那個(gè)半吊子的:=賦值操作符。真正的類型推導(dǎo),就要跟C++的泛型lambda表達(dá)式、C#的linq語法糖,或者Haskell的函數(shù)一樣,要可以自己計(jì)算出模板的類型參數(shù)的位置或者內(nèi)容,才能全方位的實(shí)現(xiàn)什么類型都不寫,都還能使用強(qiáng)類型和type rich programming帶來的好處。C++的lambda表達(dá)式上面已經(jīng)看到了,所以還是從Haskell一個(gè)老掉牙的demo開始講起吧。
今天,我們用Haskell來寫一個(gè)merge sort:
merge [] [] = [] merge [] xs = xs merge xs [] = xs merge (x:xs) (y:ys) = if x<y then x:(merge xs (y:ys)) else y:(merge (x:xs) ys) mergeSort [] = [] mergeSort xs = merge (mergeSort a) (mergeSort b) where len = length xs a = take $ len `div` 2 $ xs b = drop $ len - len `div` 2 $ xs
我們可以很清楚的看出來,merge的類型是[a] –> [a] –> [a],mergeSort的類型是[a] –> [a]。到底編譯器是怎么計(jì)算出類型的呢?
- 首先,[]告訴我們,這是一個(gè)空列表,但是類型是什么不知道,所以他是forall a –> [a]。所以merge [] [] = []告訴我們,merge的類型至少是[a] –> [b] –> [c]。
- 其次,merge [] xs = xs告訴我們,merge的類型至少是[d] –> e –> e。這個(gè)類型跟[a]->[b]->[c]求一個(gè)交集就會得到merge的更準(zhǔn)確的類型:[a] –> [b] –> [b]。
- 然后,merge xs [] = []告訴我們,merge的類型至少是f –> [g] –> f。這個(gè)類型跟[a] –> [b] –> [b]求一個(gè)交集就會得到merge的更準(zhǔn)確的類型:[a] –> [a] –> [a]。
- 最后看到那個(gè)長長的式子,根據(jù)一番推導(dǎo)之后,會發(fā)現(xiàn)[a]->[a]->[a]就是我們要的最終類型了。
- 只要把相同的技術(shù)放在mergeSort上面,就可以得到mergeSort的類型是[a]->[a]了。
當(dāng)然對于Haskell這種Hindley-Milner類型系統(tǒng)來說,只要我們在代碼里面計(jì)算出所有類型的方程,然后一個(gè)一個(gè)去解,最后就可以收斂到一個(gè)最準(zhǔn)確的類型上面了。倘若我們在迭代的時(shí)候發(fā)現(xiàn)收斂之后無解了,那這個(gè)程序就是錯(cuò)的。這種簡單粗暴的方法很容易構(gòu)造出一些只要人夠蛋定就很容易使用的語言,譬如說Haskell。
Haskell看完就可以來看C#了。C#的linq真是個(gè)好東西啊,只要不把它看成SQL,那很多事情都可以表達(dá)的。譬如說是個(gè)人都知道的linq to object啦,后面還有linq to xml,linq to sql,reactive programming,甚至是parser combinator等等。一個(gè)典型的linq的程序是長這個(gè)樣子的:
var w = from x in xs from y in ys from z in zs select f(x, y, z);
光看這個(gè)程序可能看不出什么來,因?yàn)閤s、ys、zs和f這幾個(gè)單詞都是沒有意義的。但是linq的魅力正在這里。如果from和select就已經(jīng)強(qiáng)行規(guī)定了xs、ys、zs和f的意思的話。那可擴(kuò)展性就全沒有了。因此當(dāng)我們看到一個(gè)這樣的程序的時(shí)候,其實(shí)可以是下面這幾種意思:
W f(X x, Y y, Z z); var /*IEnumerable<W>*/w = from /*X*/x in /*IEnumerable<X>*/xs from /*Y*/y in /*IEnumerable<Y>*/ys from /*Z*/z in /*IEnumerable<Z>*/zs select f(x, y, z); var /*IObservable<W>*/w = from /*X*/x in /*IObservable<X>*/xs from /*Y*/y in /*IObservable<Y>*/ys from /*Z*/z in /*IObservable<Z>*/zs select f(x, y, z); var /*IParser<W>*/w = from /*X*/x in /*IParser<X>*/xs from /*Y*/y in /*IParser<Y>*/ys from /*Z*/z in /*IParser<Z>*/zs select f(x, y, z);
var /*IQueryable<W>*/w =
from /*X*/x in /*IQueryable<X>*/xs
from /*Y*/y in /*IQueryable<Y>*/ys
from /*Z*/z in /*IQueryable<Z>*/zs
select f(x, y, z);
var /*?<W>*/w = from /*X*/x in /*?<X>*/xs from /*Y*/y in /*?<Y>*/ys from /*Z*/z in /*?<Z>*/zs select f(x, y, z);
相信大家已經(jīng)看到了里面的pattern了。只要你有一個(gè)?<T>類型,而它又支持linq provider的話,你就可以把代碼寫成這樣了。
不過我們知道,把程序?qū)懗蛇@樣并不是我們編程的目的,我們的目的是要讓程序?qū)懙米尵哂型瑯又R背景的人可以很快就看懂。為什么要看懂?因?yàn)榭傆幸惶炷銜痪S護(hù)這個(gè)程序的,這樣就可以讓另一個(gè)合格的人來繼續(xù)維護(hù)了。一個(gè)軟件是要做幾十年的,那些只有一兩年甚至只有半年生命周期的,只能叫垃圾了。
那現(xiàn)在讓我們看一組有意義的linq代碼。首先是linq to object的,求一個(gè)數(shù)組里面出現(xiàn)最多的數(shù)字是哪個(gè):
var theNumber = ( from n in numbers group by n into g select g.ToArray() into gs order by gs.Length descending select gs[0] ).First()
其次是一個(gè)parser,這個(gè)parser用來得到一個(gè)函數(shù)調(diào)用表達(dá)式:
IParser<FunctionCallExpression> Call() { return from name in PrimitiveExpression() from _1 in Text("(") from arguments in many( Expression(). Text(",") ) from _2 in Text(")") select new FunctionCallExpression { Name = name, Arguments = arguments.ToArray(), }; }
我們可以看到,一旦linq表達(dá)式里面的元素都有了自己的名字,就不會跟上面的xyz的例子一樣莫名其妙了。那這兩個(gè)例子到底為什么要用linq呢?
第一個(gè)例子很簡單,因?yàn)閘inq to object就是設(shè)計(jì)來解決這種問題的。
第二個(gè)例子就比較復(fù)雜一點(diǎn)了,為什么好好地parser要寫成這樣呢?我們知道,parser時(shí)可能會parse失敗的。一個(gè)大的parser,里面的一些小部件失敗了,那么大parser就要回滾,token stream的當(dāng)前光標(biāo)也要回滾,等等需要類似的一系列的操作。如果我們始終都讓這些邏輯都貫穿在整個(gè)parser里面,那代碼根本不能看。于是我們可以寫一個(gè)linq provider,讓SelectMany函數(shù)來處理所有的回滾操作,然后把parser寫成上面這個(gè)樣子。上面這個(gè)parser的所有的in左邊是臨時(shí)變量,所有的in右邊剛好組成了一個(gè)EBNF文法:
PrimitiveExpression "(" [Expression {"," Expression}] ")"
最后的select語句告訴我們在所有小parser都parse成功之后,如何用parse得到的臨時(shí)變量來返回一顆語法樹。整個(gè)parsing的代碼就會非常的容易看懂。當(dāng)然,前提是你必須要懂的EBNF。不過一個(gè)不懂EBNF的人,又如何能寫語法分析器呢。
那這跟類型推導(dǎo)有什么關(guān)系呢?我們會發(fā)現(xiàn)上面的所有l(wèi)inq的例子里面,除了函數(shù)簽名以外,根本沒有出現(xiàn)任何類型的聲明。而且更重要的是,這些類型盡管沒有寫出來,但是每一個(gè)中間變量的類型都是自明的。當(dāng)然這有一部分歸功于好的命名方法——也就是應(yīng)用匈牙利命名法了。剩下的部分是跟業(yè)務(wù)邏輯相關(guān)的。譬如說,一個(gè)FunctionCallExpression所調(diào)用的函數(shù)當(dāng)然也是一個(gè)Expression了。如果這是唯一的選擇,那為什么要寫出來呢?
我們可以看到,正是因?yàn)橛辛祟愋屯茖?dǎo),我們可以在寫出清晰的代碼的同時(shí),還不需要花費(fèi)那么多廢話來指定各種類型。程序員都是怕麻煩的,無論復(fù)雜的方法有多好,他總是會選擇簡單的(廢話,用復(fù)雜的那個(gè)不僅要加班修bug,還沒有漲工資。用簡單的那個(gè),先讓他過了,bug留給下一任程序員去頭疼就好了——某web程序員如是說)。類型推導(dǎo)讓type rich programming的程序?qū)懫饋砗唵瘟嗽S多。所以我們一旦有了類型推導(dǎo),就可以放心大膽的使用type rich programming了。
三、大道理
有了type rich programming,就可以讓編譯器幫我們檢查一些模式化的手都會犯的錯(cuò)誤。讓我們重溫一下這篇文章前面的一段話:
人的精力是有限的,需要一些錯(cuò)誤規(guī)避來防止引進(jìn)低級的錯(cuò)誤或者負(fù)擔(dān),保留精力解決最核心的問題。很多軟件都是這樣的。譬如說超容易配置的MSBuild、用起來巨爽無比的Visual Studio,出了問題反正用正版安裝程序點(diǎn)一下repair就可以恢復(fù)的windows,給我們帶來的好處就是——保留精力解決最核心的問題。編程語言也是如此,類型系統(tǒng)也是如此,人類發(fā)明出的所有東西,都是為了讓你可以把更多的精力放在更加核心的問題上,更少的精力放在周邊的問題上。
這讓我想起了一個(gè)在微博上看到的故事:NASA的員工在推一輛裝了衛(wèi)星的小車的時(shí)候,因?yàn)橥浛碿heck list,沒有固定號衛(wèi)星,結(jié)果衛(wèi)星一推倒在了地上摔壞了,一下子沒了兩個(gè)億的美元。
寫程序也一樣。一個(gè)代表力的變量,只能跟另一個(gè)代表力的變量相加,這就是check list。但是我們知道,每一個(gè)程序都相當(dāng)復(fù)雜,check list需要檢查的地方遍布所有文件。那難道我們在code review的時(shí)候可以一行一行仔細(xì)看嗎?這是不可能的。正因?yàn)槿绱耍覀円殉绦驅(qū)懗?#8220;讓編譯器可以檢查很多我們可能會手抖犯的錯(cuò)誤”的形式,讓我們從這些瑣碎的事情里面解放出來。
銀彈這種東西是不存在的,所以type rich programming能解決的事情就是防止手抖而犯錯(cuò)誤。有一些錯(cuò)誤不是手抖可以抖出來的,譬如說錯(cuò)誤的設(shè)計(jì),這并不是type rich programming能很好地處理的范圍。為了解決這些事情,我們就需要更多可以遵守的best practice了。
當(dāng)然,其中的一個(gè)將是DSL——domain specific language,領(lǐng)域特定語言了。敬請關(guān)注下一篇,《如何設(shè)計(jì)一門語言(十)——DSL與建模》。