如何编写带有多个参数的 JavaScript 函数(史诗指南)

函数组合很漂亮。在之前的一篇文章中,我们研究了以下工具: compose()flow()。这些组合函数允许我们创建函数管道。它们将函数排列起来,以便一个函数的输出直接流入下一个函数。当这些函数一起工作时,数据就像枫糖浆流过煎饼一样。但是当函数没有排列好时会发生什么?如果其中一些函数需要多个参数怎么办?那我们该怎么办?我们如何组合具有多个参数的函数?

这个问题的答案很简单:我们不能。

仅限一元函数组合。1 任何超过一个参数和组合都行不通。至少,没有帮助。我们不能组合接受多个参数的函数。

如果无法组合多个参数的函数,那写这篇文章干嘛?标题是彻头彻尾的谎言吗?

当然,一定有 某物 我们可以做到。毕竟,那些古怪的函数式程序员永远都在赞美组合的乐趣。如果没有办法使用多参数函数,它为什么会如此受欢迎?一定有办法让它发挥作用。

并且有一个方法。

我们作弊。

我们通过改变函数来解决这个限制。也就是说,我们可以包装或修改它们。我们 转换 将多参数函数转换为一元函数。在本文中,我们将介绍五种实现此目的的技术。(还有更多,但这些是最常见的。)

  1. 复合数据结构
  2. 部分应用
  3. 柯里化
  4. 使用 ap() 对于 get/set 问题
  5. 使用 flatMap() 对于配置问题

复合数据结构

让我们从这个问题最简单的形式开始。假设我们有一个需要两个参数的函数。我们还有另一个返回两个值的函数。……除此之外,我们已经有一个问题了。函数不能返回多个值。每个函数只能返回一个值。仅此而已。2

幸运的是,JavaScript 提供了多种方法来组合多个值。最常见的方法是使用复合数据结构。即数组和对象。3 例如,我们知道从一个函数返回两个值是不可能的。但在单个数组中返回两个值完全没有问题。

如果你使用过前端框架,你可能已经见过这种情况。React 的 useState() 函数返回一个数组中的值和一个 setter 函数。它看起来像这样:

const temperatureStatePair = useState(23);
const temperature = temperatureStatePair[0];
const setTemp = temperatureStatePair[1];

如果我们利用解构,我们可以将其浓缩为一行:

const [temperature, setTemp] = useState(23);

SolidJS 有类似的概念,信号。它使用相同的模式。

const [temperature, setTemp] = createSignal(23);

现在,假设我们正在开发一个用户界面(用户界面)作为恒温器。我们希望允许人们在摄氏度和华氏度之间切换。为了实现这一点,我们可能会编写一个转换函数。这个奇特的转换函数会将两者转换为 temperature()setTemp() 为了我们:

const celsiusToFahrenheit = t => t * 9 / 5 + 32;
const fahrenheitToCelsius = t => (t - 32) * 5 / 9;

const stateCelsiusToFahrenheit = (temperature, setTemp) => {
   const tempF = celsiusToFahrenheit(temperature);
   const setTempF = (tempC) => setTemp(fahrenheitToCelsius(tempC));
   return [tempF, setTempF];
}

我们的函数在输出过程中将摄氏度转换为华氏度(tempF)。它在输入过程中将华氏度转换为摄氏度(setTempF())。但是,请注意我们的函数需要两个参数。

创作 useState()stateCelsiusToFahrenheit()目前,我们还不能。但由于我们正在编写函数,我们可以更改它接收参数的方式。我们可以编写它,以便它需要一个数组而不是两个参数。使用参数解构,更改只有两个字符:

const stateCelsiusToFahrenheit = ([temperature, setTemp]) => {
   const tempF = celsiusToFahrenheit(temperature);
   const setTempF = (tempC) => setTemp(celsiusToFahrenheit(tempC));
   return [tempF, setTempF];
}

完成后,我们可以用 useState() 用一个 compose() 功能:4

const compose = (...fns) => x0 => fns.reduceRight(
    (x, f) => f(x),
    x0
);

const useFahrenheit = compose(
    stateCelsiusToFahrenheit,
    useState
);

我们可以用我们闪亮的新 useFahrenheit() 组件内部的功能如下:


const [tempF, setTempF] = useFahrenheit(23);

这个方法可行,但不太好,因为我们必须以摄氏度设置初始温度。我们可以将另一个函数组合到管道中来解决这个问题:




const useFahrenheit = compose(
    stateCelsiusToFahrenheit,
    useState,
    fahrenheitToCelsius,
);


const [tempF, setTempF] = useFahrenheit(74);

我们现在将状态存储为摄氏度,但使用华氏度进行 用户界面。这很巧妙,但也有点毫无意义。至少,我们在这里使用它的方式是毫无意义的。我们已经将其编码,以便我们现在可以 仅有的 使用华氏温度。因此,如果我们不在任何地方共享该状态,我们不妨将温度存储为华氏温度。我们稍后会回到这个问题并做一些更有用的事情。现在,我们有一个可行的示例。我们可以看到如何使用数组来组成多参数函数。

那么对象呢​​?

让我们回到第一个 useState() 示例。我们通过引用数组索引来提取值:

const temperatureStatePair = useState(23);
const temperature = temperatureStatePair[0];
const setTemp = temperatureStatePair[1];

这很好,但您可能已经注意到顺序是任意的。也就是说,值位于零位置没有特殊含义。setter 函数位于一位置也没有任何含义。我们可以交换它们,这不会有什么区别。5 我们必须记住每个任意插槽里有什么东西。

另一种方法是使用对象。这样,我们可以为每个值槽赋予有意义的名称。例如,我们可以重写 stateCelsiusToFahrenheit() 返回一个对象:

const stateCelsiusToFahrenheitObj = ([temperature, setTemp]) => {
   const tempF = celsiusToFahrenheit(temperature);
   const setTempF = (tempC) => setTemp(celsiusToFahrenheit(tempC));
   return { temperature: tempF, setTemperature: setTempF };
};

我们现在返回一个对象而不是数组。它有键 temperaturesetTemperature。这分别代表我们的值和 setter 函数。我们的组合管道保持不变:

const useFahrenheit = compose(
    stateCelsiusToFahrenheitObj,
    useState,
    fahrenheitToCelsius,
);

什么时候我们 使用 但是,我们的钩子有点不同。我们使用命名属性来解构组合函数:

const { temperature, setTemperature } = useFahrenheit(74);

经过这种改变,我们解构值的顺序就不再重要了。例如,我们可以这样写:

const { setTemperature, temperature } = useFahrenheit(74);

行为没有改变。

但是这种方法有一个缺点。如果我们想改变这些变量的名称,它会变得相当冗长:

const {
    temperature: tempF,
    setTemperature: setTempF
} = useFahrenheit(74);

我们有一个权衡。使用数组在解构时会提供更多的灵活性。但是,使用对象会给我们更有意义的名称,并允许我们重新排序。这两种方法都有各自的用途。

不过,无论我们选择数组还是对象,我们现在都有返回值的解决方案。我们可以将几个值放入复合数据结构中并返回该值。很简单。但如果我们有一个函数 期望 多个参数?

我们也可以使用复合数据结构。我们用一元函数包装多变量函数。例如,假设我们有一个函数 el() 用于创建 HTML 元素为字符串:

const el = (tag, contents) => `<${tag}>${contents}${tag}>`;

我们不能将此函数与其他函数组合,因为它需要多个参数。但我们可以将其包装在另一个需要数组的函数中:

const elComposable = ([tag, contents]) => el(tag, contents);

我们可以像这样使用它:



const wrapListItem = compose(
    elComposable,
    item => ['li', item],
);

const wrapList = compose(
    elComposable,
    list => ['ul', list.join('')],
    list => list.map(wrapListItem),
);

const characterList = ['Holmes', 'Watson', 'Mrs. Hudson'];
const characterListHTML = wrapList(characterList);
characterListHTML

如果我们觉得更酷,我们甚至可以创建一个实用函数。它将任何函数转换为需要数组作为参数的函数。再次,参数解构使其非常简洁:

const arrayifyArgs = (fn) => (args) => fn(...args);


const elComposable = arrayifyArgs(el);

复合数据结构几乎可以解决涉及多个参数的任何组合问题。但这不是 仅有的 解决方案。

部分应用

我们之前说过,我们的 useFahrenheit() 函数毫无用处。如果我们只使用华氏度,那么存储摄氏度就毫无意义了。但是,如果我们使用共享状态,它会变得更有趣。

假设我们替换 useState()useLocalStorage()。 (你可以找到 现成的钩子usehooks.com)。这个钩子有一个漂亮的 API。 看起来像 useState(),只不过需要额外的 key 参数。我们可以像这样使用它:

const PREFIX = 'my-clever-prefix-to-prevent-namespace-collisions-';
const [temperature, setTemperature] = useLocalStorage(23, `${PREFIX}temperature`);

它返回的形状与 useState()。因此,似乎应该有一种方法可以将其用于我们的 compose() 管道。一种方法是创建一个新函数, key 参数部分应用。

const PREFIX = 'my-clever-prefix-to-prevent-namespace-collisions-';
const tempKey = `${PREFIX}temperature`;
const useDeconflictedLocalStorage =
    (initialVal) => useLocalStorage(initialVal, tempKey);

我们创建了一个新功能, useDeconflictedLocalStorage()。它“修复”了 key 为了 uselocalStorage() 设置为特定值。完成后,我们可以使用 useDeconflictedLocalStorage() 在我们的函数管道中如前所述:

const useFahrenheit = compose(
    stateCelsiusToFahrenheitObj,
    useDeconflictedLocalStorage,
    fahrenheitToCelsius,
);

const { temperature, setTemperature } = useFahrenheit(74);

我们现在将温度以摄氏度为单位存储在本地存储中。但我们在 用户界面 好像一切都以华氏度为单位。这不再是无用的。它允许应用程序的其他部分读取 localstorage 摄氏度值。或者我们稍后可能会切换到以 用户界面

但这并不是进行部分应用的唯一方法。我们还可以使用 .bind() 方法内置于每个 JavaScript 函数中。为了说明这一点,让我们回到我们的 el() 函数。我们可以为不同类型的 HTML 元素。




const ul = el.bind(null, 'ul');
const li = el.bind(null, 'li');

在上面的代码中,我们创建了两个新函数。每个函数都部分应用了标签名称 el() 函数。这给我们返回了一个新函数。然后我们可以像这样使用这些函数:

const listify = compose(
    ul,
    list => list.join(''),
    list => list.map(li),
);

const characterList = ['Holmes', 'Watson', 'Mrs. Hudson'];
const characterListHTML = listify(characterList);
characterListHTML

重申一下,我们已 el() 并创建了两个带有“固定”参数的新函数。在此过程中,我们将二进制函数转换为6 分为两个一元函数。

然而,使用这种技术,参数的顺序很重要。假设我们反转 el()

const elReversed = (contents, tag) => `<${tag}>${contents}${tag}>`;

如果我们尝试使用 .bind()reversed (),我们只能修复 contents 参数。通常,这不如修复 tag

在创建函数时请记住这一点。如果你使用 .bind() 或者柯里化,将变化最少的数据放在最前面会有所帮助。然后,我们可以修复波动较小的参数,并创建新函数,以便根据需要经常使用。

部分应用比看上去更强大。它提供了一系列令人惊讶的工具和技术。我们将在本文的其余部分探讨其中三种。

柯里化

我们可能会发现自己做了很多偏应用。在这种情况下,我们可以通过精心设计函数来简化工作,这样只需调用它们就可以修复参数。例如,考虑我们的 useLocalStorage() 函数。假设我们像这样包装它:

const useLocalStorageCurried =
    (key) => (initialVal) => useLocalStorage(initialVal, key);

我们创建了一个新功能, useLocalStorageCurried()。它只接受一个参数, key. 当我们用 key,它返回另一个接受单个值的函数, initialVal。调用该函数会返回一个数组中的一对值。调用 useLocalStorage()

如果这听起来令人困惑,别担心。看看我们如何使用它,就会更容易理解。 useLocalStorageCurried(),我们可以创建不同用途的商店。例如:


const useTemperature = useLocalStorageCurried('temperature');
const useHeatingStatus = useLocalStorageCurried('heating');
const useCoolingStatus = useLocalStorageCurried('cooling');


const [temp, setTemp] = useTemperature(23);
const [heatingOn, setHeatingStatus] = useHeatingStatus(false);
const [coolingOn, setCoolingStatus] = useCoolingStatus(false);

柯里化是一种将多参数函数转换为一元函数的方法。我们通过将函数嵌套在一起来创建柯里化函数。调用外部函数会修复一个参数并返回一个新函数。调用该新函数会修复以下参数,依此类推。这个过程一直持续到我们拥有所需的所有参数,然后返回结果。

利用这种技术,我们可以创建一个柯里化版本 el() 功能:

const elCurried = (tag) => (contents) => `<${tag}>${contents}${tag}>`;

在此过程中,我们可以创建一些其他实用函数(均经过柯里化):

const map = (func) => (list) => list.map(func);
const join = (joinStr) => (list) => list.join(joinStr);

一旦我们完成了这些,我们就可以使用它们 compose()

const listify = compose(
    el('ul'),
    join(''),
    map(el('li')),
);

const characterList = ['Holmes', 'Watson', 'Mrs. Hudson'];
const characterListHTML = listify(characterList);
characterListHTML

有时,当人们看到这样的代码时,他们会感到害怕。这是因为它看起来与他们习惯的完全不同。让我们分解一下,看看它是如何工作的。回想一下 compose() 将数据从底部传输到顶部。我们将依次介绍每个部分:

  • 我们创作的 listify() 函数需要一个字符串数组。
  • 当我们打电话时 listify()compose() 将此字符串数组传递给 map(el('li'))
  • map(el('li')) 列表中的每个项目都用
  • 然后,Compose 将该字符串列表传递给 join('')
  • join('') 将所有列表项连接在一起,返回单个字符串值。
  • 然后,Compose 将该单个字符串值传递给 el('ul')
  • el('ul') 将单个字符串值包装在 and returns our final string.

People who are more intelligent than I have researched this style of coding. They’ve proven that you can write any program using only composition and currying. Some programming languages encourage this way of writing code, too. However, you may face some difficulties if you attempt to write in this style with JavaScript. One of those is what I call the get/set problem.

Solving the get/set problem with ap()

如果你编写了大量前端 Web 应用程序,你会发现自己一遍又一遍地做着同样的事情。其中一种模式是这样的:

  1. 从远程服务获取一些数据;
  2. 将该数据与当地状况相结合;
  3. 将这些组合数据转换成适合的格式 用户界面; 和
  4. 渲染 用户界面 使用所述数据。

在这样做的时候,你经常会发现自己遵循了另一种微模式。这可能是你经常做的事情,以至于你不再注意到。微模式如下:

  1. 从对象中取出一个或多个值;
  2. 转换这些值;然后
  3. 将结果作为新属性添加到同一个对象。7

使用可组合函数时,执行转换部分很容易。但将转换后的值恢复为起始对象的副本则有点棘手。这是因为我们需要三条信息:

  1. 输入对象;
  2. 转换后的值;以及
  3. 要在新对象中设置的属性的名称。

在组合管道中将这些部分排列起来可能很棘手。不过,为了让这个问题更加具体,让我们再次考虑我们的恒温器示例。假设我们与一个返回温度观测值数组的服务进行通信。数据可能如下所示:

const temps = [
    { time: 1715411010990, temp: 21.2, sensor: 'bakerst' },
    { time: 1715414610990, temp: 21.5, sensor: 'bakerst' },
    { time: 1715418210990, temp: 20.8, sensor: 'bakerst' },
];

我们希望获取这些值并将它们显示在表格中。但时间需要采用可读的日期格式。考虑到这一要求,我们可以编写一个日期格式化函数,如下所示:

const DTFORMAT = {timeStyle: 'medium', dateStyle: 'short'}
const formatDateForLocale = (languages) => {
    const formatter = new Intl.DateTimeFormat(languages, DTFORMAT);
    return (timestamp) => formatter.format(new Date(timestamp));
}

const formatDate = formatDateForLocale(navigator.languages);

这给了我们一个 formatDate() 执行转换的函数。我们还可以创建 getter 和 setter 函数来将内容放入和取出对象:

const getTimestamp = (tempObj) => tempObj.time;
const setReadableDate = (tempObj) => (value) => ({...tempObj, readableTime: value});

我们拥有完成所需任务所需的所有部件。但我们仍需要某种方式将它们连接在一起。我们可以使用名为 ap()。它看起来像这样:

const ap = (binaryCurriedFn) => (unaryFn) => (value) => 
    binaryCurriedFn(value)(unaryFn(value));

这有什么用呢?一种查看方法如下。此函数接受两个函数参数并返回一个一元函数。此一元函数传递 value两个都 binaryCurriedFn()unaryFn()。然后它通过 unaryFn(value) 结果作为第二个参数 binaryCurriedFn()。然后我们得到了最后一次调用的结果。

我们可以这样使用它来格式化我们的温度时间戳:

const getTimestampAndFormat = compose(formatDate, getTimestamp);

const addFormattedDate = ap(setReadableDate)(getTimestampAndFormat);
const tempsWithFormattedDates = temps.map(addFormattedDate);

我们可能还想将华氏温度添加到我们的 用户界面。为此,我们可以创建一些 getter 和 setter 函数,例如 getTempC()setTempF()。但是我们开始重复自己了。如果我们创建一些用于获取和设置的实用函数会怎么样?

const get = (key) => (obj) => obj[key];
const set = (key) => (obj) => (val) => ({...obj, [key]: val});

我们还可以创建一个 getSet() 函数会帮我们把它们组合起来:

const getSet = (setter) => (getter) => (transform) => ap(setter)(compose(transform, getter));

思考的方式 getSet() 它接受三个输入参数并返回一个函数。该函数:

  1. 使用以下方式提取值 getter()
  2. 使用以下方式转换值 transform(), 和
  3. 使用以下方法将转换后的值插入到对象中 setter()

然后我们可以在管道中使用它,如下所示:

const transformTempReadings = compose(
    getSet(set('tempF'))(get('temp'))(celsiusToFahrenheit),
    getSet(set('readableTime'))(get('time'))(formatDate),
);

const readingsForUI = temps.map(transformTempReadings);
readingsForUI

getSet() 实用程序使创建用于转换数据的管道变得方便。我发现自己经常做这种事。但组合还有另一个问题:配置问题。

解决配置问题 flatMap()

再次回到我们的恒温器示例。假设我们的应用有一些配置,我们会在应用启动时加载这些配置。例如:

const config = {
    locale: 'en-GB',
    timezone: 'GMT',
    defaultTarget: 23,
    defaultUnits: 'Celsius',
    baseHeatingRate: 3,
    baseCoolingRate: 5,
    sensors: {
        bakerst: {name: 'Baker St.', host: 'bakerst.thermostat.example.com' },
        gorvesnorsq: {name: 'Grosvenor Sq.', host: 'grosvenor.thermostat.example.com' },
        bedlam: {name: 'Bedlam', host: 'bedlam.thermostat.example.com' },
    },
};

假设我们不想将配置公开为全局变量。但假设我们再次转换传感器读数列表。我们想要编写三个使用相同配置对象的函数。

对于此任务,我们将暂时忽略有关参数排序的建议。我们将使变化最少的对象(config)最后一个参数:

const formatDateForLocale = (timestamp) => (config) =>
    (new Intl.DateTimeFormat(config.local, {timeStyle: 'medium', dateStyle: 'short'}))
        .format(new Date(timestamp));

const addReadableDate = (obj) => (config) => ({
    ...obj, readableDate: formatDateForLocale(config.locale)(obj.time)
});

const addSensorName = (obj) => (config) => ({
    ...obj, sensorName: config.sensors[obj.sensor]?.name
});

const addTempDiff = (obj) => (config) => ({
    ...obj, tempDiff: obj.temp - config.defaultTarget
});

各功能:

  1. 测量物体的温度,
  2. 然后是一个配置对象,以及
  3. 返回修改后的温度对象。

我们希望将它们组合在一起以进行所需的修改。但我们还希望将它们链接在一起,以便它们都获得相同的配置对象。为了实现这一点,我们求助于一个名为 flatMap() (也称为 chain()):

const flatMap = (binaryCurriedFn) => (unaryFn) =>
    (x) => binaryCurriedFn(unaryFn(x))(x);

如果你仔细观察,你会发现它类似于 ap().但它将转换后的值传递给 binaryCurriedFunction() 第一而不是第二。

使用 flatMap(),我们可以在组合流中将我们的函数链接在一起:

const transformTempObjs = compose(
    flatMap(addTempDiff),
    flatMap(addSensorName),
    addReadableDate
);

再次从下往上阅读,此管道:

  1. 向接收的对象添加可读日期,
  2. 查找条目的传感器名称并将其添加到对象,然后
  3. 将实际温度和目标温度之间的差值添加到对象。

它与其他合成管道没什么不同。不过,有趣的是,这些是二元函数,而不是一元函数。我们的 flatMap() 助手连接 config 选项贯穿每个函数。当我们运行 transformTempObjs(),它将所有三个额外属性添加到对象中:

transformTempObjs(temps[0])(config);

这很巧妙,但我们有一整组对象需要转换。而且必须传递 config 第二个对象不方便。最好把它翻转过来,这样 transformTempObjs() 拿走了 config 对象。我们可以使用另一个简洁的小工具来实现这一点, flip()

const flip = (binaryCurriedFn) => (b) => (a) => binaryCurriedFn(a)(b);

我们可以利用它来制作 transformTempObjs() 与数组配合良好 .map() 方法:

const flippedTransformTempObjs = flip(compose(
    flatMap(addTempDiff),
    flatMap(addSensorName),
    addReadableDate
));

const transformedReadings = temps.map(flippedTransformTempObjs(config));
console.log(transformedReadings);





我们编写了二进制柯里化函数,以便每个函数都传递相同的配置对象。我觉得这很神奇。

所以呢?

想象一下,如果我们要对编写的每个函数进行柯里化。如果我们的所有函数都经过柯里化,那么一个令人着迷的实用函数世界就会打开。我们在本文中使用了其中三个实用程序:

  1. ap()
  2. flatMap(); 和
  3. flip()

还有更多。函数式程序员将这些小工具称为“组合器”。它们是帮助我们 结合 通过组合来发挥作用。 Avaq 的要点提供了更全面的列表。玩组合器会很有趣。这有点像用类型和函数来玩数独游戏。

但事实是,你可能并不需要组合器。将事物重新排列成数组或对象通常就可以完成这项工作。此外,在你的代码中随意使用组合器可能会让你的同事难以理解。这可能是一个真正的问题。

那么,为什么要学习组合器呢?它们只会让我们的同事感到困惑。而且无论如何,你都必须对所有函数进行柯里化才能使用它们。这有什么意义呢?

作为一名 JavaScript 开发人员,您会发现自己经常编写函数。无论您是否意识到这一点,这都是事实。当然,您可以使用复合数据结构而不用其他任何东西。但这有点像限制自己只使用瑞士军刀而不使用其他工具。当然,它在大多数情况下都会起作用。但它可能不是最好的,还有其他专门的工具可用。了解部分应用、柯里化和组合器可以为您提供选择。

Leave a Reply

Your email address will not be published. Required fields are marked *

近期新闻​

编辑精选​