我想讨论一种更难的方法。
是的,没错,更难!但前提是你从头开始构建它。如果您已经有一个实用函数库,它可能会简单得多。
我们最终将编写一个如下所示的转换函数:
const transform = pipe (
countBy (identity),
Object .entries,
map (zipObj (['name', 'duplicates']))
)
我们的想法是,通过一些小的实用函数,我们可以以更简单、声明性的方式编写这样的函数,将其描述为一组转换。
下面我们开始构建我们自己的实用函数列表。这些是根据Ramda 的函数建模的(免责声明:我是 Ramda 的作者),但我们在这里编写自己的版本。我们的功能将比 Ramda 中的更简单,功能也稍逊一筹。但它们将涵盖大量用例并且易于扩展。
第一步:count
我们要做的第一件事是按键计数。
我们想拿
['M', 'M', 'M', 'S', 'S', 'S', 'R', 'C', 'S', 'S', 'S']
并将其转换为
{M: 3, S: 6, R: 1, C: 1}
我们可以把这个函数写成对我们的值进行简单的归约,像这样1:
const count = (xs) =>
xs .reduce ((a, x) => ((a [x] = (a [x] || 0) + 1), a), Object .create (null))
我们可以到此为止,但很容易看到一些有用的扩展。也许我们不关心大小写,想把所有小写的ms 和大写的Ms 一起算。或者我们有一个人的生日列表,我们想按出生年份计算他们。或多种情况中的任何一种。如果我们将该函数扩展为也采用转换函数,我们可以一次涵盖所有这些。它可能看起来像这样2:
const countBy = (fn) => (xs) =>
xs .reduce ((a, x) => ((a [fn (x)] = (a [fn (x)] || 0) + 1), a), Object .create (null))
我们可以这样使用它:
const decade = ({dob}) => `${dob .slice (0, 3)}0-${dob .slice (2, 3)}9`
const people = [{name: 'sue', dob: '1950-10-31'}, {name: 'bob', dob: '1967-04-28'},
{name: 'jan', dob: '1972-02-26'}, {name: 'ron', dob: '1966-01-05'},
{name: 'lil', dob: '1961-04-17'}, {name: 'tim', dob: '1958-09-12'}]
countBy (decade) (people) //=> {"1950-59": 2, "1960-69": 3, "1970-79": 1}
现在我们可以通过将identity 函数传递给countBy 来重写count 来使用它。 identity 是微不足道的 (x) => x,事实证明它非常有用:
const count = countBy (identity)
第 2 步:分离属性
所以现在我们有了{M: 3, S: 6, R: 1, C: 1},但是我们需要把它变成一个对象数组,每个属性一个。有一个内置的 JS 函数,Object.entries。 (Ramda 有自己的版本,toPairs,早于Object.entries,但现在没有理由不使用内置版本。)
那么我们就可以了
Object .entries ({M: 3, S: 6, R: 1, C: 1})
回来
[['M', 3], ['S', 6], ['R', 1], ['C', 1]]
第 3 步:转换为最终形式,使用 zipObj
现在我们想将['M', 3] 转换为{name: 'M', duplicates: 3}。
一个不错的可能性是编写一个zip* 函数,将两个相等长度的列表压缩到一个新结构中。有许多可能的变体,但让我们编写一个简单地接受一个键列表和一个相同大小的值列表,并将它们配对为对象的条目3:
const zipObj = (ks) => (vs) =>
ks .reduce ((a, k, i) => ((a [k] = vs [i]), a), {})
有了这个,我们可以调用
zipObj (['name', 'duplicates']) (['M', 3])
//=> {name: 'M', duplicates: 3}
第 4 步:mapping 所有条目的结果
我们现在可以将单个条目转换为其最终形式,但我们必须使用一个充满此类条目的数组来执行此操作。我们知道如何使用Array.prototype.map 做到这一点。但是直接使用有两个问题。一个有点模糊:Array.prototype.mappasses additional parameters(索引和整个数组)除了我们的初始值。有时可能会导致问题。第二个问题很简单:对于这里提倡的风格,纯函数比对象方法好得多。
所以我们写了一个普通函数map,它简单地调用Array.prototype.map4:
const map = (fn) => (xs) =>
xs .map ((x) => fn (x))
我们可以这样使用它:
map (zipObj (['name', 'duplicate'])) (
[['M', 3], ['S', 6], ['R', 1], ['C', 1]]
)
产生最终结果。
在注意到Array.prototype.map 的缺陷之后,我们在map 的实现中使用它似乎很奇怪,但我相信它是有道理的。我们解决了我们定义map这一事实所指出的第二个问题。第一个问题可以通过管理Array.prototype.map 来解决,只将值传递给它。 Ramda 从头开始重写这些,这样做可以勉强提高一点性能,但随着时间的推移,性能优势会被侵蚀,并且代码变得更加繁琐。
第 5 步:pipe将这些功能组合在一起
为了使它成为一组漂亮的声明性步骤,我们还需要一个部分:一种将函数粘合在一起的方法,以便一个接一个地运行。我的图像是数据流经的管道,因此使用pipe 实现此功能。这与函数组合的数学概念非常接近,Ramda 包括 pipe 和 compose,它们的工作方式非常相似,但它们的参数以相反的顺序列出。
pipe 是一个简单的函数:
const pipe = (...fns) => (x) =>
fns .reduce ((a, fn) => fn (a), x)
我们只是迭代函数,将上一次调用的结果传递给下一个函数,从传入的值开始。
有了这个,我们现在可以把它们放在一起了。
第 6 步:组合我们的功能
有了以上所有内容,我们现在可以写出原始问题的解决方案:
const countBy = (fn) => (xs) =>
xs .reduce ((a, x) => ((a [fn (x)] = (a [fn (x)] || 0) + 1), a), Object .create (null))
const identity = (x) =>
x
const count = countBy (identity)
const zipObj = (ks) => (vs) =>
ks .reduce ((a, k, i) => ((a [k] = vs [i]), a), {})
const map = (fn) => (xs) =>
xs .map ((x) => fn (x))
const pipe = (...fns) => (x) =>
fns .reduce ((a, fn) => fn (a), x)
const transform = pipe (
countBy (identity),
Object .entries,
map (zipObj (['name', 'duplicates']))
)
const arr = ['M', 'M', 'M', 'S', 'S', 'S', 'R', 'C', 'S', 'S', 'S']
console .log (transform (arr))
console .log (transform (['toString', 'toString', 'valueOf']))
.as-console-wrapper {max-height: 100% !important; top: 0}
请注意,这里唯一的自定义代码是transform。其余的是我们可以为我们系统的其他部分和未来系统保留的实用程序函数。
课程
-
可以以强大的方式组合小型辅助函数,使我们的大型问题的解决方案更易于编写。
-
不断地将代码分解成越来越小的部分,直到我们发现那些潜伏在问题中的辅助函数是值得的。
-
一旦您拥有大量的辅助函数,就可以很快解决这些问题。实际上,我在两分钟内就在 Ramda 中为此写了my solution。最终版本的不同之处仅在于添加了count,其中Ramda 版本需要countBy (identity),而我使用Ramda 的toPairs 而不是Object.entries。
开放式问题
-
性能:这几乎肯定不如 danh 的答案那么好。这最终会循环数据几次。有时这是一个真正的问题。您将必须根据您的具体情况来决定更简洁的代码是否值得一些低效。没有普遍的答案。但我倾向于使用尽可能简单的代码来运行。如果我的系统速度不符合我的标准,我会分析以找到最重要的瓶颈并首先解决这些瓶颈。很少会像这样修改代码。
-
API 设计:我在这里将请求的输出作为硬性要求。它可能是;我不知道OP的需求。但我会发现countBy、{M: 3, S: 6, R: 1, C: 1} 的输出对于大多数处理来说是一个更有用的结构。我建议始终检查更简单的结构是否比定制的结构更能满足需求。
1 使用Object .create (null) 代替{} 使我们能够在对另一个答案的评论中处理issue georg raised。通过使用null 原型创建,我们可以避免在累加器对象中对'toString' 等属性进行虚假匹配。
2 我们应该注意到countBy 的低效率:它计算fn(x) 两次。解决这个问题很简单,但它让我们远离了这里的主要观点。老实说,我也经常不打扰。
3 有一整套函数涉及压缩两个大小相等的数组。如果我们扩展它,最终我们可能会在更通用的zipWith 之上重写zipObj,就像我们在countBy 之上重写count 的方式一样。这留给读者作为练习。
4 最后,我们可能希望扩展这个函数,使其也适用于对象,也许还有其他类型。但这已经足够了。