【译】 Ramda函数签名

1,635 阅读18分钟

查看Ramda的over函数的文档,我们首先看到两行如下所示:

Lens s a -> (a -> a) -> s -> s
Lens s a = Functor f => (a -> f a) -> s -> f s

对于有FP语言经验的人来到Ramda,这些可能看起来很熟悉,但对于JavaScript开发人员来说,它们不太容易理解。在这里,我们将描述如何在Ramda文档中阅读这些内容,以及如何将它们用于您自己的代码。

最后,当我们了解了这些是如何工作的,我们就会明白人们为什么需要它们。

命名类型

许多ML-influenced的语言,包括Haskell,使用一种标准的方式描述函数的签名。随着函数式编程在JavaScript中越来越常见,这种风格的签名正慢慢地变得标准化。我们借用并改编了Haskell的描述函数签名的方式,用于Ramda。

我们不会试图创建一个描述,而是通过示例简单地捕捉到这些签名的本质。

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

这里有一个简单的函数,length,它接受一个字符串类型的word,并返回字符串长度,这是一个数字。函数上方的注释是签名。它首先是函数名,然后是分隔符“::”,然后是函数的实际描述。函数的输入,然后是箭头,然后是输出。您通常会在源代码中看到上面写的箭头“->”,在输出文档中看到的箭头为“→”。他们的意思完全一样。

我们在箭头前后放置的是参数的类型,而不是它们的名称。在这个描述级别上,这正是我们要说的,接受字符串并返回数字。

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

在本例中,函数接受两个参数,一个位置(数字)和一个单词(字符串),它返回单个字符串或空字符串。

在javascript中,与haskell不同,函数可以接受多个参数。为了显示一个需要两个参数的函数,我们用逗号分隔两个输入参数,并用括号将组括起来:(数字,字符串)。与许多语言一样,javascript函数参数是有位置的,所以顺序很重要。(字符串、数字)的含义完全不同。

当然,对于接受三个参数的函数,我们只扩展括号内逗号分隔的列表:

// 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…