声明式循环与命令式循环

declarative loop vs imperative loop

我正在尝试将我的编程风格从 命令式[​​=30=] 切换到 声明式,但是有一些概念困扰着我,比如性能当谈到 循环 时。例如,我有一个原始的 DATA,在操作它之后我希望得到 3 个预期结果:itemsHashnamesHash, rangeItemsHash

// original data

const DATA = [
  {id: 1, name: 'Alan', date: '2021-01-01', age: 0},
  {id: 2, name: 'Ben', date: '1980-02-02', age: 41},
  {id: 3, name: 'Clara', date: '1959-03-03', age: 61},
]

...

// expected outcome

// itemsHash => {
//   1: {id: 1, name: 'Alan', date: '2021-01-01', age: 0},
//   2: {id: 2, name: 'Ben', date: '1980-02-02', age: 41},
//   3: {id: 3, name: 'Clara', date: '1959-03-03', age: 61},
// }

// namesHash => {1: 'Alan', 2: 'Ben', 3: 'Clara'}

// rangeItemsHash => {
//   minor: [{id: 1, name: 'Alan', date: '2021-01-01', age: 0}],
//   junior: [{id: 2, name: 'Ben', date: '1980-02-02', age: 41}],
//   senior: [{id: 3, name: 'Clara', date: '1959-03-03', age: 61}],
// }
// imperative way

const itemsHash = {}
const namesHash = {}
const rangeItemsHash = {}

DATA.forEach(person => {
  itemsHash[person.id] = person;
  namesHash[person.id] = person.name;
  if (person.age > 60){
    if (typeof rangeItemsHash['senior'] === 'undefined'){
      rangeItemsHash['senior'] = []
    }
    rangeItemsHash['senior'].push(person)
  }
  else if (person.age > 21){
    if (typeof rangeItemsHash['junior'] === 'undefined'){
      rangeItemsHash['junior'] = []
    }
    rangeItemsHash['junior'].push(person)
  }
  else {
    if (typeof rangeItemsHash['minor'] === 'undefined'){
      rangeItemsHash['minor'] = []
    }
    rangeItemsHash['minor'].push(person)
  }
})
// declarative way

const itemsHash = R.indexBy(R.prop('id'))(DATA);
const namesHash = R.compose(R.map(R.prop('name')),R.indexBy(R.prop('id')))(DATA);

const gt21 = R.gt(R.__, 21);
const lt60 = R.lte(R.__, 60);
const isMinor = R.lt(R.__, 21);
const isJunior = R.both(gt21, lt60);
const isSenior = R.gt(R.__, 60);


const groups = {minor: isMinor, junior: isJunior, senior: isSenior };

const rangeItemsHash = R.map((method => R.filter(R.compose(method, R.prop('age')))(DATA)))(groups)

为了达到预期的结果,命令式[​​=30=]只循环一次,而声明式循环至少3次(itemsHash,namesHash ,rangeItemsHash )。哪一个更好?在性能上有什么折衷吗?

.map(f).map(g) == .map(compose(g, f)) 的方法类似,您可以编写 reducer 以确保一次传递即可获得所有结果。

编写声明性代码与循环一次或多次的决定没有任何关系。

// Reducer logic for all 3 values you're interested in
// id: person
const idIndexReducer = (idIndex, p) => 
  ({ ...idIndex, [p.id]: p });

// id: name
const idNameIndexReducer = (idNameIndex, p) => 
  ({ ...idNameIndex, [p.id]: p.name });
  
// Age
const ageLabel = ({ age }) => age > 60 ? "senior" : age > 40 ? "medior" : "junior";
const ageGroupReducer = (ageGroups, p) => {
  const ageKey = ageLabel(p);
  
  return {
    ...ageGroups,
    [ageKey]: (ageGroups[ageKey] || []).concat(p)
  }
}

// Combine the reducers
const seed = { idIndex: {}, idNameIndex: {}, ageGroups: {} };
const reducer = ({ idIndex, idNameIndex, ageGroups }, p) => ({
  idIndex: idIndexReducer(idIndex, p),
  idNameIndex: idNameIndexReducer(idNameIndex, p),
  ageGroups: ageGroupReducer(ageGroups, p)
})

const DATA = [
  {id: 1, name: 'Alan', date: '2021-01-01', age: 0},
  {id: 2, name: 'Ben', date: '1980-02-02', age: 41},
  {id: 3, name: 'Clara', date: '1959-03-03', age: 61},
]

// Loop once
console.log(
  JSON.stringify(DATA.reduce(reducer, seed), null, 2)
);

主观部分:值不值?我不这么认为。我喜欢简单的代码,根据我自己的经验,在处理有限的数据集时,从 1 到 3 个循环通常是不明显的。

所以,如果使用 Ramda,我会坚持:

const { prop, indexBy, map, groupBy, pipe } = R;

const DATA = [
  {id: 1, name: 'Alan', date: '2021-01-01', age: 0},
  {id: 2, name: 'Ben', date: '1980-02-02', age: 41},
  {id: 3, name: 'Clara', date: '1959-03-03', age: 61},
];

const byId = indexBy(prop("id"), DATA);
const nameById = map(prop("name"), byId);
const ageGroups = groupBy(
  pipe(
    prop("age"), 
    age => age > 60 ? "senior" : age > 40 ? "medior" : "junior"
  ),
  DATA
);

console.log(JSON.stringify({ byId, nameById, ageGroups }, null, 2))
<script src="https://cdn.jsdelivr.net/npm/ramda@0.27.1/dist/ramda.min.js"></script>

对此我有几个回应。

首先,您是否测试过 知道性能是一个问题?在甚至接近成为应用程序瓶颈的代码上完成了太多的性能工作。这通常以牺牲代码的简单性和清晰度为代价。所以我通常的规则是首先编写简单明了的代码,尽量不要对性能愚蠢,但永远不要过分担心它。然后,如果我的应用程序慢得令人无法接受,请对其进行基准测试以找出导致最大问题的部分,然后优化这些部分。我很少让那些地方相当于循环三次而不是一次。但当然有可能发生。

如果确实如此,并且您确实需要在单个循环中执行此操作,那么在 reduce 调用之上执行此操作并不难。我们可以这样写:

// helper function
const ageGroup = ({age}) => age > 60 ? 'senior' : age > 21 ? 'junior' : 'minor'

// main function
const convert = (people) =>
  people.reduce (({itemsHash, namesHash , rangeItemsHash}, person, _, __, group = ageGroup (person)) => ({
    itemsHash: {...itemsHash, [person .id]: person},
    namesHash: {...namesHash, [person .id]: person.name},
    rangeItemsHash: {...rangeItemsHash, [group]: [...(rangeItemsHash [group] || []), person]}
  }), {itemsHash: {}, namesHash: {}, rangeItemsHash: {}})

// sample data
const data = [{id: 1, name: 'Alan', date: '2021-01-01', age: 0}, {id: 2, name: 'Ben', date: '1980-02-02', age: 41}, {id: 3, name: 'Clara', date: '1959-03-03', age: 61}]

// demo
console .log (JSON .stringify (
  convert (data)
, null, 4))
.as-console-wrapper {max-height: 100% !important; top: 0}

(您可以删除 JSON .stringify 调用以证明引用在各种输出哈希之间共享。)

我可以从两个方向来清理这段代码。

首先是使用 Ramda。它有一些功能可以帮助简化这里的一些事情。使用 R.reduce,我们可以消除我用来允许我将默认参数 group 添加到 reduce 签名的烦人的占位符参数,并保持 expressions-over-statements 样式编码。 (我们也可以用 R.call 做一些事情。)将 evolveassocover 等函数一起使用,我们可以像这样使它更具声明性:

// helper function
const ageGroup = ({age}) => age > 60 ? 'senior' : age > 21 ? 'junior' : 'minor'

// main function
const convert = (people) =>
  reduce (
    (acc, person, group = ageGroup (person)) => evolve ({
      itemsHash: assoc (person.id, person),
      namesHash: assoc (person.id, person.name),
      rangeItemsHash: over (lensProp (group), append (person))
    }) (acc), {itemsHash: {}, namesHash: {}, rangeItemsHash: {minor: [], junior: [], senior: []}}, 
    people
  )

// sample data
const data = [{id: 1, name: 'Alan', date: '2021-01-01', age: 0}, {id: 2, name: 'Ben', date: '1980-02-02', age: 41}, {id: 3, name: 'Clara', date: '1959-03-03', age: 61}]


// demo
console .log (JSON .stringify (
  convert (data)
, null, 4))
.as-console-wrapper {max-height: 100% !important; top: 0}
<script src="//cdnjs.cloudflare.com/ajax/libs/ramda/0.27.1/ramda.js"></script>
<script> const {reduce, evolve, assoc, over, lensProp, append} = R   </script>

与前一版本相比,此版本的一个小缺点是需要在累加器中预定义类别 seniorjuniorminor。我们当然可以写一个 lensProp 的替代方案,以某种方式处理默认值,但这会让我们走得更远。

我可能会去的另一个方向是注意到代码中仍然存在一个潜在的严重性能问题,一个名为 the reduce ({...spread}) anti-pattern 的 Rich Snapp。为了解决这个问题,我们可能想在 reduce 回调中改变我们的累加器对象。 Ramda——就其非常哲学的本质——不会帮你解决这个问题。但是我们可以定义一些辅助函数,在我们解决这个问题的同时清理我们的代码,像这样:

// utility functions
const push = (x, xs) => ((xs .push (x)), x)
const put = (k, v, o) => ((o[k] = v), o)
const appendTo = (k, v, o) => put (k, push (v, o[k] || []), o)

// helper function
const ageGroup = ({age}) => age > 60 ? 'senior' : age > 21 ? 'junior' : 'minor'

// main function
const convert = (people) =>
  people.reduce (({itemsHash, namesHash , rangeItemsHash}, person, _, __, group = ageGroup(person)) => ({
    itemsHash: put (person.id, person, itemsHash),
    namesHash: put (person.id, person.name, namesHash),
    rangeItemsHash: appendTo (group, person, rangeItemsHash)
  }), {itemsHash: {}, namesHash: {}, rangeItemsHash: {}})

// sample data
const data = [{id: 1, name: 'Alan', date: '2021-01-01', age: 0}, {id: 2, name: 'Ben', date: '1980-02-02', age: 41}, {id: 3, name: 'Clara', date: '1959-03-03', age: 61}]

// demo
console .log (JSON .stringify (
  convert (data)
, null, 4))
.as-console-wrapper {max-height: 100% !important; top: 0}

但最后,如前所述,我不会这样做,除非可以证明性能存在问题。我认为这样的 Ramda 代码更好:

const ageGroup = ({age}) => age > 60 ? 'senior' : age > 21 ? 'junior' : 'minor'

const convert = applySpec ({
  itemsHash: indexBy (prop ('id')),
  nameHash: compose (fromPairs, map (props (['id', 'name']))),
  rangeItemsHash: groupBy (ageGroup)
})

const data = [{id: 1, name: 'Alan', date: '2021-01-01', age: 0}, {id: 2, name: 'Ben', date: '1980-02-02', age: 41}, {id: 3, name: 'Clara', date: '1959-03-03', age: 61}]

console .log (JSON .stringify(
  convert (data)
, null, 4))
.as-console-wrapper {max-height: 100% !important; top: 0}
<script src="//cdnjs.cloudflare.com/ajax/libs/ramda/0.27.1/ramda.js"></script>
<script> const {applySpec, indexBy, prop, compose, fromPairs, map, props, groupBy} = R </script>

在这里,为了保持一致性,我们可能希望 ageGroup 无点 and/or 将其内联到主函数中。这并不难,另一个答案给出了一个例子。我个人觉得这样更具可读性。 (可能还有更简洁的 namesHash 版本,但我没时间了。)

这个版本循环三遍,正是你所担心的。有时这可能是个问题。但除非它是一个 demonstrable 问题,否则我不会花太多精力在这上面。干净的代码本身就是一个有用的目标。