镜头等效进化或镜头到多个值

Lens equivalent of evolve or lens to multiple values

我的理解是镜头是包含 getset 值的函数。

我有这个辅助功能:

const overEach = uncurryN(3, 
  fn => lenses =>
    lenses.length > 0 ? 
    compose(...map(flip(over)(fn), lenses)) : 
    identity
);

正在使用

const annual = ["yearsPlayed", "age"];
const annualInc = overEach(
  inc,
  map(lensProp, annual),
);

console.log(
  annualInc({
    jersey: 148,
    age: 10,
    yearsPlayed: 2,
    id: 3.14159
  })
);

输出:

{
    jersey: 41,
    age: 11,
    yearsPlayed: 3,
    id: 3.14159
}

这很有趣,因为(就像进化一样),我可以定义某种形状的东西是如何改变的。这比进化更好,因为它让我清楚地分离了对数据形状和我对它所做的事情的关注。这比进化更糟糕,因为它创建了一个我从未使用过的中间值。我拥有的镜头越多,我创造的中间值就越多。

{
    jersey: 148,
    age: 10,
    yearsPlayed: 3,
    id: 3.14159
}

我很想知道是否有一种方法可以定义指向多个值的镜头。 compose(lenseIndices([1,7,9]), lensProp('parents'), lensIndex(0)) 可能指向三个不同人中的第一个 parent。

在我看来这真的应该是可能的,但我不知道要搜索什么,我宁愿不 re-invent 轮子(特别是因为我没有带着镜头在杂草丛中yet),如果可以而且也已经完成了。

I'd be curious to know if there's a way to define a lens that points to more than one value.

我们对“镜头”的直觉应该是它“聚焦”在数据结构的特定部分。所以真的,不。镜头就是要处理特定的东西。 (但请参阅下面的更新,该更新表明此特定内容 而不是 必须是单个 属性。)

Ramda's issue #2457 更详细地讨论了镜头的使用。


evolve 相比,我认为我不同意你对你的函数提供的额外灵活性的解释。事实上,如果我要实现它,我可能会在 evolve 之上这样做:

const {evolve, fromPairs, inc} = R

const overEach = (fn, names) =>
  evolve (fromPairs (names .map (name => [name, fn])))

const annualInc = overEach (inc, ["yearsPlayed", "age"])

console .log (annualInc ({
  jersey: 148,
  age: 10,
  yearsPlayed: 2,
  id: 3.14159
}))
<script src="//cdnjs.cloudflare.com/ajax/libs/ramda/0.27.1/ramda.js"></script>

并且 evolve 让您可以轻松地为不同的属性选择不同的函数,允许您嵌套转换,并且非常具有声明性。

overEach 只是允许我们将相同的转换函数应用于许多不同的节点。这当然很有用,但似乎不像 evolve.

的正常情况那么常见

更新

我想澄清一下我上面说的。虽然 lenses focus 在数据结构的特定部分,但这并不意味着它们只能影响一个 field属性 一个对象。我们需要更全面地考虑这一点。该部分可以是多个字段,可能带有子字段。我认为这是最容易通过示例来描述的。

让我们假设您有完善的 box 函数来描述笛卡尔网格上的一个框。它具有只读 positionwidthheight 属性,以及移动它的方法、缩放它,列出角落,找到区域 所有这些都正常运行,返回新框而不是改变原始框。您对这段代码非常满意:

const box = (l, t, w, h) => ({
  move: (dx, dy) => box (l += dx, t += dy, w, h),
  scale: (dw, dh) => box (l, t, w *= dw, h *= dh),
  area: () => w * h,
  get position () { return {x: l, y: t} },
  get width () { return w},
  get height () { return h },
  corners: () => [{x: l, y: t}, {x: l + w, y: t}, {x: l + w, y: t + h}, {x: l, y: t + h}],
  toString: () => `Box (left: ${l}, top: ${t}, height: ${h}, width: ${w})`
})

但是现在您想将您的工具应用到一个新的环境中,您的小部件看起来像这样:

const widget = {
  topLeft: {x: 126, y: 202},
  bottomRight: {x: 776, y: 682},
  borderColor: 'red',
  borderWidth: 3,
  backgroundUrl: 'http://example.com/img.png',
  // ...
}

虽然 topRightbottomLeft 点是描述矩形的兼容方式,但您将不得不重写一堆已经处理框的代码来处理这些新的小部件。此外,盒子似乎是这种情况的逻辑视图。高度和宽度似乎比右下角更重要。这里我们可以用镜头来处理顾虑。也就是说,我们可以完全在盒子里思考,从widget中提取一个盒子,通过调整盒子来调整值。我们只需要写一个镜头就可以了:

const boxLens = lens (
  ({topLeft: {x: x1, y: y1}, bottomRight: {x: x2, y: y2}}) => 
    box (x1, y1, x2 - x1, y2 - y1),
  ({position: {x, y}, width, height}, widget) => ({
    ...widget, 
    topLeft: {x, y}, 
    bottomRight: {x: x + width, y: y + height}
  })
)

现在我们可以像用框描述一样处理小部件的位置和范围:

view (boxLens, widget) .toString ()
//=> "Box (left: 126, top: 202, height: 480, width: 650)"

view (boxLens, widget) .corners ()
//=> [{x: 126, y: 202}, {x: 776, y: 202}, {x: 776, y: 682}, {x: 126, y: 682}]

set (boxLens, box (200, 150, 1600, 900), widget)
//=> {topLeft: {x: 200, y: 150}, bottomRight: {x: 1800, y: 1050}, borderColor: "red", ...}

over (boxLens, box => box .scale (.5, .5), widget)
//=> {topLeft: {x: 126, y: 202}, bottomRight: {x: 451, y: 442}, borderColor: "red", ...}

const moveWidget = (dx, dy) => 
  over(boxLens, box => box .move (dx, dy))

moveWidget (10, 50) (widget)
//=> {topLeft: {x: 136, y: 252}, bottomRight: {x: 786, y: 732}, borderColor: "red", ...}

您可以在以下代码段中确认这一点:

const {lens, view, set, over} = R

const box = (l, t, w, h) => ({
  move: (dx, dy) => box (l += dx, t += dy, w, h),
  scale: (dw, dh) => box (l, t, w *= dw, h *= dh),
  area: () => w * h,
  get position () { return {x: l, y: t} },
  get width () { return w},
  get height () { return h },
  corners: () => [{x: l, y: t}, {x: l + w, y: t}, {x: l + w, y: t + h}, {x: l, y: t + h}],
  toString: () => `Box (left: ${l}, top: ${t}, height: ${h}, width: ${w})`
})

const boxLens = lens(
  ({topLeft: {x: x1, y: y1}, bottomRight: {x: x2, y: y2}}) => box (x1, y1, x2 - x1, y2 - y1),
  ({position: {x, y}, width, height}, widget) => ({
    ...widget, 
    topLeft: {x, y}, 
    bottomRight: {x: x + width, y: y + height}
  })
)

const widget = {
  topLeft: {x: 126, y: 202},
  bottomRight: {x: 776, y: 682},
  borderColor: 'red',
  borderWidth: 3,
  backgroundUrl: 'http://example.com/img.png',
  // ...
}

console .log (
  view (boxLens, widget) .toString ()
)
console .log (
  view (boxLens, widget) .corners ()
)
console .log (
  set (boxLens, box (200, 150, 1600, 900), widget)
)
console .log (
  over (boxLens, box => box .scale (.5, .5), widget)
)

const moveWidget = (dx, dy) => 
  over(boxLens, box => box .move (dx, dy))

console .log (
  moveWidget (10, 50) (widget)
)
.as-console-wrapper {max-height: 100% !important; top: 0}
<script src="//cdnjs.cloudflare.com/ajax/libs/ramda/0.27.1/ramda.min.js"></script>

重点

这表明我们可以使用镜头一次处理多个字段,正如 Mrk Sef 的自我回答也解释的那样。但是我们必须以某种与原始同构的方式处理它们。这实际上是镜头的一个非常强大的用途。但这并不意味着我们可以简单地使用它们来处理任意属性。

到目前为止我学到了什么

这可能不是个好主意。问题是镜片需要具有某些特性才能工作。这些属性之一是:

view(lens, set(lens, a, store)) ≡ a — If you set a value into the store, and immediately view the value through the lens, you get the value that was set.

如果您希望镜头在没有进一步限制的情况下指向多个值,则该信息必须(以某种方式)在被更改的数据结构中进行编码。如果一个键被设置为一个数组,该数组编码它自己的大小。但是,如果设置数组实际上对应于设置 something else,那么 something else 的某些子集必须与数组同构(增长、收缩、重新排序,整个 shebang)。这样就可以随时来回转换了。

如果您对进一步的限制感到满意,您可以做更多的事情,但结果并不理想。

这是一个功能齐全(据我所知)的镜头实现,它指向多个属性但限制了允许设置的属性。

const subsetOf = pipe(without, length, equals(0));
const subset = flip(subsetOf);

const lensProps = propNames => lens(
  pick(propNames),
  (update, data) => 
    subset(keys(update), propNames) ?
    ({ ...data, ...update }) :
    call(() => {throw new Error("OH NO! LENS LAW BROKEN!");})
);

正在使用:

const annualLens = lensProps(["yearsPlayed", "age"]);

const timmy = {
  jersey: 148,
  age: 10,
  yearsPlayed: 2,
  id: 3.14159
};

console.log(
  "View Timmy's Annual Props: ", 
  view(annualLens, timmy)
);
console.log(
  "Set Timmy's Annual Props: ", 
  set(annualLens, {yearsPlayed: 100, age: 108}, timmy)
);
console.log(
  "Update Timmy's Annual Props: ", 
  over(annualLens, map(inc), timmy)
);

// Break the LAW
set(annualLens, {newKey: "HelloWorld"}, timmy);

输出:

View Timmy's Annual Props: { age: 10, yearsPlayed: 2 }
Set Timmy's Annual Props: { jersey: 148, age: 108, yearsPlayed: 100, id: 3.14159 }
Update Timmy's Annual Props: { jersey: 148, age: 11, yearsPlayed: 3, id: 3.14159 }
Error: OH NO! LENS LAW BROKEN!

您可以想象编写一个采用路径而不是名称的版本,但是从那以后为了使用 set,这实际上并没有帮助,您需要知道镜头的路径期待 set.

但情况变得更糟。你可以组成这些类型的镜头,但实际上没有任何意义:

compose(lensIndex(0), lensProps(["a","b"]), lensProp("b")) 

相同
compose(lensIndex(0), lensProp("b"))

所以虽然它没有破坏任何东西,但它很快就会变得非常无趣。实际上,它的唯一用途是作为构图中的 'outermost' 镜头。即使那样,它也可能必须受到限制才能有用。

好的一面是,作为最外面的镜头,它实际上可以在没有中间物体的情况下改变多个值。但这不是很好,因为您可以使用 evolve 作为传递给 over 的函数,并且在不丢失任何东西的情况下加入额外的功能。