【譯】 Ramda函數簽名

查看Ramda的over函數的文檔,咱們首先看到兩行以下所示:javascript

Lens s a -> (a -> a) -> s -> s
Lens s a = Functor f => (a -> f a) -> s -> f s
複製代碼

對於有FP語言經驗的人來到Ramda,這些可能看起來很熟悉,但對於JavaScript開發人員來講,它們不太容易理解。在這裏,咱們將描述如何在Ramda文檔中閱讀這些內容,以及如何將它們用於您本身的代碼。java

最後,當咱們瞭解了這些是如何工做的,咱們就會明白人們爲何須要它們。git

命名類型

許多ML-influenced的語言,包括Haskell,使用一種標準的方式描述函數的簽名。隨着函數式編程在JavaScript中愈來愈常見,這種風格的簽名正慢慢地變得標準化。咱們借用並改編了Haskell的描述函數簽名的方式,用於Ramda。程序員

咱們不會試圖建立一個描述,而是經過示例簡單地捕捉到這些簽名的本質。github

// length :: String -> Number
const length = word => word.length;
length('abcde'); //=> 5
複製代碼

這裏有一個簡單的函數,length,它接受一個字符串類型的word,並返回字符串長度,這是一個數字。函數上方的註釋是簽名。它首先是函數名,而後是分隔符「::」,而後是函數的實際描述。函數的輸入,而後是箭頭,而後是輸出。您一般會在源代碼中看到上面寫的箭頭「->」,在輸出文檔中看到的箭頭爲「→」。他們的意思徹底同樣。typescript

咱們在箭頭先後放置的是參數的類型,而不是它們的名稱。在這個描述級別上,這正是咱們要說的,接受字符串並返回數字。shell

// charAt :: (Number, String) -> String
const charAt = (pos, word) => word.charAt(pos);
charAt(9, 'mississippi'); //=> 'p'
複製代碼

在本例中,函數接受兩個參數,一個位置(數字)和一個單詞(字符串),它返回單個字符串或空字符串。編程

在javascript中,與haskell不一樣,函數能夠接受多個參數。爲了顯示一個須要兩個參數的函數,咱們用逗號分隔兩個輸入參數,並用括號將組括起來:(數字,字符串)。與許多語言同樣,javascript函數參數是有位置的,因此順序很重要。(字符串、數字)的含義徹底不一樣。數組

固然,對於接受三個參數的函數,咱們只擴展括號內逗號分隔的列表:bash

// foundAtPos :: (Number, String, String) -> Boolean
const foundAtPos = (pos, char, word) => word.charAt(pos) === char;
foundAtPos(6, 's', 'mississippi'); //=> true
複製代碼

對於任何更大的有限參數列表也是如此。

注意ES6風格的箭頭函數定義和這些類型聲明之間的並行性。如函數的定義是

(pos, word) => word.charAt(pos);
複製代碼

經過將參數名替換爲它們的類型、正文替換爲它返回的值的類型以及胖箭頭「=>」替換爲瘦箭頭「->」,咱們獲得簽名:

// (Number, String) -> String
複製代碼

列表類型的數據

咱們常用相同類型的值的列表。若是咱們想要一個函數在一個列表中添加全部數字,咱們可使用:

// addAll :: [Number] -> Number
const addAll = nbrs => nbrs.reduce((acc, val) => acc + val, 0);
addAll([8, 6, 7, 5, 3, 0, 9]); //=> 38
複製代碼

此函數的輸入是一個數字列表。咱們基本上能夠將其視爲數組。爲了描述給定類型的列表,咱們將該類型名用方括號「[]」括起來。字符串列表爲[字符串],布爾值列表爲[布爾值],數字列表的列表爲[[數字]]。

固然,列表也能夠是函數的返回值:

// findWords :: String -> [String]
const findWords = sentence => sentence.split(/\s+/);
findWords('She sells seashells by the seashore');
//=> ["She", "sells", "seashells", "by", "the", "seashore"]
複製代碼

咱們應該絕不驚訝地意識到,咱們能夠將這些結合起來:

// addToAll :: (Number, [Number]) -> [Number]
const addToAll = (val, nbrs) => nbrs.map(nbr => nbr + val);
addToAll(10, [2, 3, 5, 7]); //=> [12, 13, 15, 17]
複製代碼

此函數接受數字和數字列表,並返回新的數字列表。重要的是要意識到這就是簽名告訴咱們的所有內容。

函數類型

還有一個很是重要的類型咱們尚未真正討論過。函數編程是關於函數的,咱們傳遞函數做爲參數,接收做爲其餘函數的返回值的函數。咱們也須要表示這些。

In fact, we've already seen how we represent functions. Every signature line documented a particular function. We reuse the technique above in the small for the higher-order functions used in our signatures.

實際上,咱們已經看到了如何表示函數。每一個簽名行都記錄了一個特定的函數。對於簽名中使用的高階函數,咱們重用上述表示法。

// applyCalculation :: ((Number -> Number), [Number]) -> [Number]
const applyCalculation = (calc, nbrs) => nbrs.map(nbr => calc(nbr));
applyCalculation(n => 3 * n + 1, [1, 2, 3, 4]); //=> [4, 7, 10, 13]
複製代碼

這裏,函數calc是由(Number -> Number)描述的,它就像咱們的頂級函數簽名,只是用括號將其正確分組爲一個單獨的單元。咱們能夠一樣描述返回一個函數的函數:

// makeTaxCalculator :: Number -> (Number -> Number)
const makeTaxCalculator = rate => base =>
    Math.round(100 * base + base * rate) / 100;
const afterSalesTax = makeTaxCalculator(6.35); // tax rate: 6.35%
afterSalesTax(152.83); //=> 162.53
複製代碼

makeTaxCalculator接受以百分比(鍵入Number)表示的稅率,並返回一個新函數,該函數自己接受一個數字並返回一個數字。再次,咱們描述了(數字→數字)返回的函數,它使得整個函數的簽名→(數字→數字)。

柯里化

使用Ramda,咱們可能不會編寫上面那樣makeTaxCalculator。Currying是Ramda的中心,咱們可能會利用它。

相反,在Ramda中,人們極可能會編寫一個柯里化的calculateTax函數,該函數能夠像maketaxcalculator同樣使用(若是這是您想要的話),也能夠分次傳值使用:

// calculateTax :: Number -> Number -> Number
const calculateTax = R.curry((rate,  base) =>
    Math.round(100 * base + base * rate) / 100);
const afterSalesTax = calculateTax(6.35); // tax rate: 6.35%
afterSalesTax(152.83); //=> 162.53
  // OR 
calculateTax(8.875, 49.95); //=> 54.38
複製代碼

柯里化的函數能夠直接傳入兩個參數並返回一個值,或者只傳入一個參數並返回一個正在等待第二個參數的函數。爲此,咱們使用數字→數字→數字。

柯里化的函數的簽名老是這樣,由‘→’分隔的一系列類型。由於其中一些類型自己多是函數,因此可能有帶括號的子結構,這些子結構自己也有箭頭。這是徹底能夠接受的:

// someFunc :: ((Boolean, Number) -> String) -> (Object -> Boolean) ->
//             (Object -> Number) -> Object -> String
複製代碼

範型類型變量

若是您使用過map,您就會知道它至關靈活:

map(word => word.toUpperCase(), ['foo', 'bar', 'baz']); //=> ["FOO", "BAR", "BAZ"]
map(word => word.length, ['Four', 'score', 'and', 'seven']); //=> [4, 5, 3, 5]
map(n => n * n, [1, 2, 3, 4, 5]); //=> [1, 4, 9, 16, 25]
map(n => n % 2 === 0, [8, 6, 7, 5, 3, 0, 9]); //=> [true, true, false, false, false, true, false]
複製代碼

上面的這些map函數,會有下面的這些類型簽名:

// map :: (String -> String) -> [String] -> [String]
// map :: (String -> Number) -> [String] -> [Number]
// map :: (Number -> Number) -> [Number] -> [Number]
// map :: (Number -> Boolean) -> [Number] -> [Boolean]
複製代碼

但顯然還有更多的可能性。咱們不能簡單地把它們都列出來。爲了解決這個問題,類型簽名不只處理具體的類,如數字、字符串和對象,還處理泛型類的表示。

咱們如何描述map?很簡單。第一個參數是一個函數,它接受一個類型的元素,並返回第二個類型的元素。(這兩種類型沒必要不一樣。)第二個參數是該函數輸入類型的元素列表。它返回該函數輸出類型的元素列表。

咱們能夠這樣描述:

// map :: (a -> b) -> [a] -> [b]
複製代碼

咱們不使用具體的類型,而是使用通用的佔位符、單個字符字母來表示任意類型。

很容易就能把它們和具體的類型區分開來。這些是完整的單詞,按照慣例,是大寫的。泛型類型變量只有a、b、c等。偶爾,若是有很好的緣由,咱們可能會使用字母表後面的字母,若是它有助於理解泛型可能表示的類型(對於鍵和值,請考慮k和v,或者對於數字,請考慮n),但大多數狀況下,咱們只使用字母表開頭的這些類型。

注意,一旦在簽名中使用了一個泛型類型變量,它就表示一個對於同一個變量的全部使用都是固定的值。咱們不能在簽名的一部分中使用b,而後在其餘地方重用它,除非在整個簽名中二者都必須是相同的類型。此外,若是簽名中的兩個類型必須相同,那麼咱們必須爲它們使用相同的變量。

看這樣一中狀況。map(n=>n*n,[1,2,3]);/=>[1,4,9]是(Number→Number)→[Number]→[Number],因此若是咱們要匹配(a→b)→[a]→[b],那麼a和b都指向數字。這不是問題。咱們仍然有兩個不一樣的類型變量,由於在某些狀況下它們是不一樣的。

參數化類型

有些類型更復雜。咱們能夠很容易地想象一個類型表明一組類似的項,咱們稱之爲一個Box。可是沒有一個Box能容納全部的值;每一個Box只能容納一種類型的項。當咱們討論一個Box時,咱們老是須要指定一個類型給Box。

// makeBox :: Number -> Number -> Number -> [a] -> Box a
const makeBox = curry((height, width, depth, items) => /* ... */);

// addItem :: a -> Box a -> Box a
const addItem = curry((item, box) => /* ... */);
複製代碼

這就是咱們如何指定一個未知類型A:參數化成Box A。這能夠在須要類型的任何地方使用,做爲參數或做爲函數的返回。固然,咱們也能夠用一個更具體的類型參數化類型,即糖果Box或Rocks盒。(雖然這是合法的,但咱們目前在Ramda並無這樣作。也許咱們只是不想被指責像一盒石頭同樣笨。)

沒必要只有一個類型參數。咱們可能有一個字典類型(Dictionary type),在它的鍵的類型和它使用的值的類型上參數化。能夠寫成 Dictionary k v。這也說明了咱們可能使用單個字母的地方,而不是字母表中的初始字母。

Ramda自己並無不少這樣的聲明,可是咱們可能會發現本身在自定義代碼中常用這樣的東西。它們的最大用途是支持類型類,因此咱們應該描述它們。

類型別名

有時咱們的類型會難以描述,由於它們的內部複雜性或者太通用。Haskell容許使用類型別名來簡化對簽名的理解。Ramda也借用了這個概念,儘管它使用得很謹慎。

這個想法很簡單。若是咱們有一個參數化類型的User String,其中該String是用來表示name的,而且咱們想要更具體地說明在生成URL時須要name這個字符串類型,那麼咱們能夠建立以下類型的別名:

// toUrl :: User Name u => Url -> u -> Url
//     Name = String
//     Url = String
const toUrl = curry((base, user) => base +
user.name.toLowerCase().replace(/\W/g, '-'));
toUrl('http://example.com/users/', {name: 'Fred Flintstone', age: 24});
//=> 'http://example.com/users/fred-flintstone'
複製代碼

別名Name和Url顯示在「=」的左側。它們的等效值顯示在右側。

如前所述,這也能夠用於建立更復雜類型的簡單別名。Ramda中的許多函數都使用Lens,而且使用類型別名簡化了Lens的類型:

//     Lens s a = Functor f => (a -> f a) -> s -> f s
複製代碼

稍後咱們將嘗試分解該複雜值,但如今應該足夠清楚,不管Lens s a 表明什麼,它下面只是複雜表達式的別名,Functor f => (a -> f a) -> s -> f s。

類型約束

有時,咱們但願以某種方式限制能夠在簽名中使用的泛型類型。好比寫一個maximum函數,能夠對Numbers、Strings、Dates進行操做,但不能對其餘對象進行操做。咱們要描述這些類型,對於這些類型,a小於b將始終返回有意義的結果。咱們定義此類型爲Ord。

// maximum :: Ord a => [a] -> a
const maximum = vals => reduce(
  (curr, next) => next > curr ? next : curr, head(vals), 
  tail(vals)
)
maximum([3, 1, 4, 1]); //=> 4
maximum(['foo', 'bar', 'baz', 'qux', 'quux']); //=> 'qux'
maximum(
 [new Date('1867-07-01'),  new Date('1810-09-16'), new Date('1776-07-04')]
); //=> new Date("1867-07-01")
複製代碼

上面的maximum簽名中,在開頭添加了一個約束節,用右雙箭頭將其與其他部分分隔開。Ord a⇒[a]→a表示maximum接受某種類型的元素集合,但該類型必須符合Ord。

在動態類型化的javascript中,沒有簡單的方法能夠在不向每一個參數,甚至每一個列表的每一個值添加類型檢查的狀況下強制執行此類型約束。但通常來講,咱們的類型簽名是這樣的。當咱們在簽名中要求[a]時,沒法保證用戶不會經過咱們[1,2,'a',false,undefined,[42,43],foo:bar,new date,null]。所以,咱們的整個類型註釋只能是描述性,而不是編譯器強制的,就像在haskell中那樣。

Ramda函數上最多見的類型約束是由javascript Fantasyland規範指定的類型約束。

在前面討論map函數時,咱們只討論了在值列表上使用map函數。但map的概念比這更爲廣泛。咱們能夠map一個樹型結構、一本字典、一個只包含一個值的普通包裝器或許多其餘類型。

能夠被map的事物的概念也是一種數學上的類型,也就是所謂的函子。函子只是一種類型,它包含一個受一些簡單法則約束的map方法。ramda的map函數將在咱們的類型上調用map方法,假設咱們沒有傳遞一個列表(或ramda已知的其餘類型),可是傳遞了帶有map的東西,咱們但願它像一個函子同樣工做。

爲了在簽名中描述這一點,咱們在簽名中添加了一個約束部分:

// map :: Functor f => (a -> b) -> f a -> f b
複製代碼

請注意,約束塊沒必要只有一個約束。咱們能夠有多個約束,用逗號分隔並用括號括起來:

// weirdFunc :: (Functor f, Monoid b, Ord b) => (a -> b) -> f a -> f b

複製代碼

不詳細說明它作了什麼,或者它如何使用monoid或ord,咱們至少能夠看到須要提供哪些類型的函數才能正確地運行。

[^強類型]:有一些很好的工具能夠解決javascript的這個缺點,包括在語言技術方面,如Ramda的姐妹項目、保護區、要更強類型化的javascript擴展(如Flow和typescript),以及編譯爲javascript的更強類型語言(如ClojureScript、ElmPureScript)。

多個標籤

有時,與其試圖找到簽名的最通用版本,不如直接單獨列出幾個相關的簽名。它們做爲兩個單獨的JSDoc標記包含在Ramda源代碼中,最後在文檔中做爲兩個不一樣的行:

// getIndex :: a -> [a] -> Number
//          :: String -> String -> Number
const getIndex = curry((needle, haystack) => haystack.indexOf(needle));
getIndex('ba', 'foobar'); //=> 3
getIndex(42,  [7, 14, 21, 28, 35, 42, 49]); //=> 5
複製代碼

顯然,若是咱們選擇的話,咱們能夠作兩個以上的簽名。但請注意,這不該該太常見。目標是編寫足夠通用的簽名來捕獲咱們的用法,而不是抽象到實際上掩蓋了函數的用法。若是咱們只須要一個簽名就能夠作到這一點,咱們可能應該這樣作。若是須要兩個,就這樣吧。可是若是咱們有一長串簽名,那麼咱們可能會缺乏一個通用的抽象。

Ramda雜項

參數數量不定的函數

將這種風格的簽名從haskell移植到javascript中涉及到幾個問題。Ramda團隊已經臨時解決了這些問題,而且這些解決方案仍然會發生變化。

在haskell中,全部函數都具備固定的參數。可是javasScript不是。Ramda的filp函數就是一個很好的例子。這是一個簡單的概念:接受任何函數並返回一個交換前兩個參數順序的新函數。

// flip :: (a -> b -> ... -> z) -> (b -> a -> ... -> z)
const flip = fn => function(b, a) {
  return fn.apply(this, [a, b].concat([].slice.call(arguments, 2))); 
}; 
flip((x, y, z) => x + y + z)('a', 'b', 'c'); //=> 'bac'
複製代碼

這個示例展現了咱們如何處理參數數量不定的函數的簽名:咱們只使用省略號。

簡單對象

There are several ways we could choose to represent plain Javascript objects. Clearly we could just say Object, but there are times when something else seems to be called for. When an object is used as a dictionary of like-typed values (as opposed to its other role as a Record), then the types of the keys and the values can become relevant. In some signatures Ramda uses "{k: v}" to represent this sort of object.

咱們能夠選擇幾種方法來表示普通的javascript對象。很明顯,咱們能夠直接說「反對」,但有時彷佛須要別的東西。當一個對象被用做相似類型值的字典(而不是它做爲記錄的其餘角色)時,鍵和值的類型就能夠變得相關。在一些簽名中,Ramda使用「k:v」來表示這類對象。

// keys :: {k: v} -> [k]
// values :: {k: v} -> [v]
// ...
keys({a: 86, b: 75, c: 309}); //=> ['a', 'b', 'c']
values({a: 86, b: 75, c: 309}); //=> [86, 75, 309]
複製代碼

並且,和往常同樣,這些能夠用做函數調用的結果:

// makeObj :: [[k,v]] -> {k: v}
const makeObj = reduce((obj, pair) => assoc(pair[0], pair[1], obj), {});
makeObj([['x', 10], ['y', 20]]); //=> {"x": 10, "y": 20}
makeObj([['a', true], ['b', true], ['c', false]]);
//=> {a: true, b: true, c: false}
複製代碼

Record類型

Record類型更像是對象的類型,以下:

// display :: {name: String, age: Number} -> (String -> Number -> String) -> String
const display = curry((person, formatter) => 
                      formatter(person.name, person.age));
const formatter = (name, age) => name + ', who is ' + age + ' years old.';
display({name: 'Fred', age: 25, occupation: 'crane operator'}, formatter);
//=>  "Fred, who is 25 years old."
複製代碼

複雜的簽名示例:over函數簽名

到這裏,咱們應該有足夠的信息來理解over函數的簽名:

Lens s a -> (a -> a) -> s -> s
Lens s a = Functor f => (a -> f a) -> s -> f s
複製代碼

咱們從類型別名開始,Lens s a = Functor f ⇒ (a → f a) → s → f s。這告訴咱們類型Lens由兩個通用變量s和a參數化。咱們知道在一個lens中使用的f變量的類型有一個約束:它必須是一個Functor。考慮到這一點,咱們能夠看到Lens是兩個參數的柯里化函數,第一個參數是從泛型類型a的值到參數化類型F a的值的函數,第二個參數是泛型類型s的值。結果是參數化類型F s的值。可是它作了什麼?咱們不知道。咱們不知道。咱們的類型簽名告訴咱們不少關於函數的信息,但它們並不能回答關於函數實際做用的問題。咱們能夠假定在某個地方必須調用f a的map方法,由於這是類型函數定義的惟一函數,可是咱們不知道如何或爲何調用該map。儘管如此,咱們知道Lens是一個功能,正如所描述的,咱們能夠用它來指導咱們的理解。

over函數被描述爲一個包含三個參數的柯里化函數,一個剛分析過的Lens a s,一個從泛型類型a到同一類型的函數,以及一個泛型類型sS的值。整個函數返回一個類型s的值。

可是爲何?

如今咱們知道了如何讀寫這些簽名。爲何咱們要這樣作,爲何函數式程序員如此迷戀它們?

有幾個很好的理由。首先,一旦咱們習慣了它們,咱們就能夠從一行元數據中得到關於函數的許多內容。它們簡潔地表達了函數的全部重要內容,除了它實際的做用。

ut more important than this is the fact that these signatures make it extremely easy to think about our functions and how they combine. If we were given this function:

比這更重要的是,這些簽名使得咱們很是容易思考咱們的函數以及它們如何結合。若是咱們給出這樣一個函數:

foo :: Object -> Number
複製代碼

map函數,咱們已經看到過:

map :: (a -> b) -> [a] -> [b]
複製代碼

then we can immediately derive the type of the function map(foo) by noting that if we substitute Object for a and Number for b, we satisfy the signature of the first parameter to map, and hence by currying we will be left with the remainder: 而後,咱們能夠人容易的得出一個map(foo)函數的類型簽名,a替換object,用b替換number:

map(foo) :: [Object] -> [Number]
複製代碼

咱們能夠經過函數的簽名來識別它們是如何鏈接在一塊兒以構建更大的函數的。可以作到這一點是函數式編程的關鍵特性之一。類型簽名使得這樣作容易得多。

英文原文:github.com/ramda/ramda…

相關文章
相關標籤/搜索