【译】 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…

相关文章
相关标签/搜索