如何正确顺序链接map和filter函数

7

我非常喜欢使用Array.prototype.mapfilterreduce来定义数据转换。 不幸的是,在最近涉及大型日志文件的项目中,我不能再多次循环遍历我的数据...

我的目标:

我想创建一个函数,通过链接.filter.map方法,而不是立即映射到数组上,组成一次循环访问数据的函数。 也就是说:

const DataTransformation = () => ({ 
    map: fn => (/* ... */), 
    filter: fn => (/* ... */), 
    run: arr => (/* ... */)
});

const someTransformation = DataTransformation()
    .map(x => x + 1)
    .filter(x => x > 3)
    .map(x => x / 2);

// returns [ 2, 2.5 ] without creating [ 2, 3, 4, 5] and [4, 5] in between
const myData = someTransformation.run([ 1, 2, 3, 4]); 

我的尝试:

这个答案这篇博客文章的启发,我开始编写一个Transduce函数,用于将对象映射为对象数组。

const filterer = pred => reducer => (acc, x) =>
    pred(x) ? reducer(acc, x) : acc;

const mapper = map => reducer => (acc, x) =>
    reducer(acc, map(x));

const Transduce = (reducer = (acc, x) => (acc.push(x), acc)) => ({
    map: map => Transduce(mapper(map)(reducer)),
    filter: pred => Transduce(filterer(pred)(reducer)),
    run: arr => arr.reduce(reducer, [])
});

问题:

Transduce代码片段的问题在于它是“反向”运行的...我链式调用的最后一个方法是首先被执行的:

const someTransformation = Transduce()
    .map(x => x + 1)
    .filter(x => x > 3)
    .map(x => x / 2);

// Instead of [ 2, 2.5 ] this returns []
//  starts with (x / 2)       -> [0.5, 1, 1.5, 2] 
//  then filters (x < 3)      -> [] 
const myData = someTransformation.run([ 1, 2, 3, 4]);

或者更抽象地说:

Go from:

Transducer(concat).map(f).map(g) == (acc, x) => concat(acc, f(g(x)))

To:

Transducer(concat).map(f).map(g) == (acc, x) => concat(acc, g(f(x)))

Which is similar to:

mapper(f) (mapper(g) (concat))
我想我知道为什么会发生这种情况,但是我无法在不改变函数“接口”的情况下解决它。
问题:
如何使我的Transduce方法按正确的顺序链接filter和map操作?
注释:
- 我刚开始学习一些我尝试做的事情的命名。如果我错误地使用了Transduce术语或有更好的描述问题的方法,请告诉我。 - 我知道可以使用嵌套的for循环来完成相同的操作:

const push = (acc, x) => (acc.push(x), acc);
const ActionChain = (actions = []) => {
  const run = arr =>
    arr.reduce((acc, x) => {
      for (let i = 0, action; i < actions.length; i += 1) {
        action = actions[i];

        if (action.type === "FILTER") {
          if (action.fn(x)) {
            continue;
          }

          return acc;
        } else if (action.type === "MAP") {
          x = action.fn(x);
        }
      }

      acc.push(x);
      return acc;
    }, []);

  const addAction = type => fn => 
    ActionChain(push(actions, { type, fn }));

  return {
    map: addAction("MAP"),
    filter: addAction("FILTER"),
    run
  };
};

// Compare to regular chain to check if 
// there's a performance gain
// Admittedly, in this example, it's quite small...
const naiveApproach = {
  run: arr =>
    arr
      .map(x => x + 3)
      .filter(x => x % 3 === 0)
      .map(x => x / 3)
      .filter(x => x < 40)
};

const actionChain = ActionChain()
  .map(x => x + 3)
  .filter(x => x % 3 === 0)
  .map(x => x / 3)
  .filter(x => x < 40)


const testData = Array.from(Array(100000), (x, i) => i);

console.time("naive");
const result1 = naiveApproach.run(testData);
console.timeEnd("naive");

console.time("chain");
const result2 = actionChain.run(testData);
console.timeEnd("chain");
console.log("equal:", JSON.stringify(result1) === JSON.stringify(result2));

这是我在代码片段中的尝试:
  • 这是我的尝试代码:

const filterer = pred => reducer => (acc, x) =>
  pred(x) ? reducer(acc, x) : acc;

const mapper = map => reducer => (acc, x) => reducer(acc, map(x));

const Transduce = (reducer = (acc, x) => (acc.push(x), acc)) => ({
  map: map => Transduce(mapper(map)(reducer)),
  filter: pred => Transduce(filterer(pred)(reducer)),
  run: arr => arr.reduce(reducer, [])
});

const sameDataTransformation = Transduce()
  .map(x => x + 5)
  .filter(x => x % 2 === 0)
  .map(x => x / 2)
  .filter(x => x < 4);
  
// It's backwards:
// [-1, 0, 1, 2, 3]
// [-0.5, 0, 0.5, 1, 1.5]
// [0]
// [5]
console.log(sameDataTransformation.run([-1, 0, 1, 2, 3, 4, 5]));


map: map => Transduce(mapper(map)(reducer)),意味着返回一个函数,该函数首先进行映射,然后执行原始的归约操作。 - Jonas Wilms
转换器构建一个堆栈。尝试运行:arr => arr.reduceRight(reducer, []) - user6445533
@ftor 我想让 run 中的 reducer 成为之前传递的所有 mapfilter 方法的组合。我认为问题不在于用数据减少我的 arr 的顺序,而是我未能正确返回新的 reducer 方法。例如:mapper(f) (mapper(g) (concat)); 返回类似于 (acc, x) => concat(acc, g(f(x))) 的 reducer,而我的 Transduce(concat).map(f).map(g) 返回 (acc, x) => concat(acc, f(g(x))) - user3297291
2个回答

16

在我们更好地了解之前

我真的很喜欢链接...

我明白你的想法,但你会发现强制将程序通过链接API是不自然的,并且在大多数情况下更麻烦而不值得。

const Transduce = (reducer = (acc, x) => (acc.push(x), acc)) => ({
  map: map => Transduce(mapper(map)(reducer)),
  filter: pred => Transduce(filterer(pred)(reducer)),
  run: arr => arr.reduce(reducer, [])
});

I think I understand why it happens, but I can't figure out how to fix it without changing the “interface” of my function.

问题确实出在您的Transduce构造函数上。您的map和filter方法将map和pred堆叠在转换器链的外部,而不是嵌套在内部。
以下是我实现的Transduce API,它按正确的顺序评估映射和过滤器。我还添加了一个日志方法,以便我们可以看到Transduce的行为。

const Transduce = (f = k => k) => ({
  map: g =>
    Transduce(k =>
      f ((acc, x) => k(acc, g(x)))),
  filter: g =>
    Transduce(k =>
      f ((acc, x) => g(x) ? k(acc, x) : acc)),
  log: s =>
    Transduce(k =>
      f ((acc, x) => (console.log(s, x), k(acc, x)))),
  run: xs =>
    xs.reduce(f((acc, x) => acc.concat(x)), [])
})

const foo = nums => {
  return Transduce()
    .log('greater than 2?')
    .filter(x => x > 2)
    .log('\tsquare:')
    .map(x => x * x)
    .log('\t\tless than 30?')
    .filter(x => x < 30)
    .log('\t\t\tpass')
    .run(nums)
}

// keep square(n), forall n of nums
//   where n > 2
//   where square(n) < 30
console.log(foo([1,2,3,4,5,6,7]))
// => [ 9, 16, 25 ]


未开发的潜力

这个答案的启发...

在阅读我写的那个答案时,您可能会忽略了其中Trans的通用性。在这里,我们的Transduce仅尝试使用数组工作,但它实际上可以与任何具有空值([])和concat方法的类型一起工作。这两个属性组成了称为Monoids的类别,如果我们不利用transducer可以处理此类别中任何类型的能力,那么我们将自己置于劣势。

以上,我们在run方法中硬编码了初始累加器[],但这应该作为参数提供 - 就像我们使用iterable.reduce(reducer, initialAcc)一样

除此之外,两个实现本质上是等价的。最大的区别在于,链接的答案中提供的Trans实现本身就是一个monoid,而这里的Transduce不是。 Transconcat方法中整洁地实现了transducer的组合,而Transduce(上面)则在每个方法中混合了组合。将其作为monoid使我们能够像处理所有其他monoid一样理性地处理Trans,而不必将其理解为具有独特的mapfilterrun方法的专用链接接口。

我建议从Trans开始构建,而不是创建自己的自定义API


想两全其美

所以我们学到了统一接口的宝贵经验,并且我们理解Trans本质上很简单。但是,您仍然想要那个甜蜜的链接API。好吧,好的...

我们将再次实现Transduce,但这次我们将使用Trans monoid来实现。在这里,Transduce持有一个Trans值而不是一个续集(Function)。

其他所有内容都保持不变 - foo只需要进行1个微小的更改,就可以产生相同的输出。

// generic transducers
const mapper = f =>
  Trans(k => (acc, x) => k(acc, f(x)))

const filterer = f =>
  Trans(k => (acc, x) => f(x) ? k(acc, x) : acc)

const logger = label =>
  Trans(k => (acc, x) => (console.log(label, x), k(acc, x)))

// magic chaining api made with Trans monoid
const Transduce = (t = Trans.empty()) => ({
  map: f =>
    Transduce(t.concat(mapper(f))),
  filter: f =>
    Transduce(t.concat(filterer(f))),
  log: s =>
    Transduce(t.concat(logger(s))),
  run: (m, xs) =>
    transduce(t, m, xs)
})

// when we run, we must specify the type to transduce
//   .run(Array, nums)
// instead of
//   .run(nums)

展开此代码段以查看最终实现-当然,您可以跳过定义单独的mapperfiltererlogger,并直接在Transduce上定义它们。我认为这样更易读。

// Trans monoid
const Trans = f => ({
  runTrans: f,
  concat: ({runTrans: g}) =>
    Trans(k => f(g(k)))
})

Trans.empty = () =>
  Trans(k => k)

const transduce = (t, m, xs) =>
  xs.reduce(t.runTrans((acc, x) => acc.concat(x)), m.empty())

// complete Array monoid implementation
Array.empty = () => []

// generic transducers
const mapper = f =>
  Trans(k => (acc, x) => k(acc, f(x)))
  
const filterer = f =>
  Trans(k => (acc, x) => f(x) ? k(acc, x) : acc)
  
const logger = label =>
  Trans(k => (acc, x) => (console.log(label, x), k(acc, x)))

// now implemented with Trans monoid
const Transduce = (t = Trans.empty()) => ({
  map: f =>
    Transduce(t.concat(mapper(f))),
  filter: f =>
    Transduce(t.concat(filterer(f))),
  log: s =>
    Transduce(t.concat(logger(s))),
  run: (m, xs) =>
    transduce(t, m, xs)
})

// this stays exactly the same
const foo = nums => {
  return Transduce()
    .log('greater than 2?')
    .filter(x => x > 2)
    .log('\tsquare:')
    .map(x => x * x)
    .log('\t\tless than 30?')
    .filter(x => x < 30)
    .log('\t\t\tpass')
    .run(Array, nums)
}

// output is exactly the same
console.log(foo([1,2,3,4,5,6,7]))
// => [ 9, 16, 25 ]


总结

所以,我们从一堆lambda开始,然后使用单子使事情变得更简单。 Trans单子具有明显的优势,因为单子接口已知,并且通用实现非常简单。但是我们很固执,或者可能有我们没有设置的目标-我们决定构建神奇的Transduce链接API,但我们使用坚如磐石的Trans单子来实现它,这为我们提供了所有Trans的功能,但也将复杂性保持得很好。


点链接癖好者匿名

这里是我写的关于方法链接的另外两个最近的答案


1
太棒了!我本来想通过跳过一些“抽象”的东西并使代码不那么通用(即仅适用于数组)来简化自己的工作,但现在我看到这并不总是奏效...我必须承认,你提到的一些概念(如η-转换和Monoids)对我来说仍然很难理解。但我真的很感激你没有只停留在快速修复上(“但那很丑”-哈哈!),而是花时间逐步解释帮助你以结构化方式解决此类问题的概念。 - user3297291
不错!请注意,eta转换也适用于元组函数,无需对其进行柯里化。 - Bergi
@Bergi,我觉得有点不对劲。我的直觉告诉我应该是可能的,但早些时候我尝试过,可能犯了错误或其他什么问题。感谢你发现了这个问题,它显著地改善了答案。 - Mulan

0

我认为你需要改变你的实现顺序:

const filterer = pred => reducer => (x) =>pred((a=reducer(x) )?x: undefined;

const mapper = map => reducer => (x) => map(reducer(x));

然后您需要将运行命令更改为:

run: arr => arr.reduce((a,b)=>a.concat([reducer(b)]), []);

而且默认的reducer必须是

x=>x

然而,这种方式过滤器将无法工作。您可以在过滤函数中抛出 undefined,并在运行函数中捕获:

run: arr => arr.reduce((a,b)=>{
try{
 a.push(reducer(b));
}catch(e){}
return a;
}, []);

const filterer = pred => reducer => (x) =>{
 if(!pred((a=reducer(x))){
   throw undefined;
 }
 return x;
};

不过,总的来说,我认为在这种情况下 for 循环更加优雅...


我认为这不起作用,因为我的 pred 是一个 x -> bool 函数,而 mapx -> y。这使得 reducer 先运行,返回一个数组(至少在我的当前示例中是这样)。或者我有什么地方理解错误吗? - user3297291
@user3297291 你是对的。你还需要更改run和默认的reducer http://jsbin.com/laqezabihe/edit?console - Jonas Wilms
问题的关键似乎在于支持“filter”。我同意使用“for”循环选项要比诉诸“try {} catch() {}”更好。 - user3297291

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接