在单线程环境中,由可变数据类型引起的典型问题类别是什么?

What is a typical class of issues that is caused by mutable data types in a single-threaded environment?

提问人: 提问时间:11/4/2019 更新时间:11/5/2019 访问量:308

问:

JS 通过事件循环对并发进行建模。因此,没有竞争条件。那么,在程序的主要范围内,以下类型的安全操作的缺点是什么,可以证明任何警告的合理性:

const m = new Map([["foo", true]]);

//...

m.set("bar", false);

即使我将清空,这也不会引起任何问题,因为每个依赖于的操作都应该考虑空的情况。mm

也许有人可以举例说明可变数据类型带来的一类典型问题。

我知道这个问题可能过于基于意见,所以如果您认为它不适合,请随时关闭它。

提前致谢!

JavaScript的 函数式编程 不变性 可变

评论

0赞 Akxe 11/5/2019
其中很多都随着 TypeScript 的出现而褪色,因为只要提供正确的类型,它就会强制您每次都进行检查。

答:

0赞 customcommander 11/4/2019 #1

[...]因为每个依赖于的操作都应该考虑空的情况m

给事物命名是很困难的。假设事情可能很危险。

我认为自己是一个务实的开发人员,有时突变是必须发生的。但是,您的工作是了解可能出错的地方,并教育您周围的人了解危险,以便他们做出明智的决定。

我曾经在一次代码审查中指出,这个函数正在改变它的参数:(它大致看起来像这样)

function foo(bar) {
  const baz = {...bar};
  baz.a.b.c = 10;
  return baz;
}

该函数的作者回答说:“不,我以前克隆过它。所以这个功能是'纯粹的'“。

如果我没有花时间和那个人坐下来,我们可能会遇到一个重大的生产问题。事实证明,他们正在改变应用程序状态,因此我们进行了几次假阳性测试。

对我来说,这是变异数据时可能发生的最糟糕的情况:混乱。

由突变引起的错误可能很难追溯。

我总是鼓励团队中的人不要为“不可能的情况”编写代码,因为这通常会导致代码中充斥着“以防万一”的检查,这增加了维护工作量并破坏了我们对代码的信心。

但是,如果您允许不受控制地访问您的数据,“不可能的情况”就在拐角处。

我已经证明,人们在不知不觉中确实会变异事物。当你的团队中有不同经验水平的人时,你能做的最好的事情就是:

  1. 永远不要假设任何事情
  2. 教他们
  3. 使用库强制执行不可变性

也许不是你所期望的“学术”答案,但我想我可以分享一些技巧。

评论

0赞 11/4/2019
混乱或失去以方程式方式推理代码的能力也是我的第一个担忧。但是,我在编码时通常不会考虑特定值,而是考虑类型。因此,只要类型不改变,我就觉得有点安全。你能为可变数据类型场景提供这样一个不可能的案例吗?
0赞 Hitmands 11/5/2019 #2

JS 通过事件循环对并发进行建模。因此,没有竞争条件。

这并不完全详尽,您还可以通过跨多个子进程运行程序来获得 javascript 中的并发性,在这种情况下,具有多个能够改变相同内存引用的线程确实可能导致争用条件或死锁。是的,不变性是用于保证线程安全的设计模式之一:[基本上]强制共享数据为只读

这是一篇很好的文章,解释了为什么以及如何在多线程环境(如 java)中遇到争用条件。


你是对的,在单线程语言中改变内存引用并没有错,实际上这就是 javascript 中很长一段时间以来的做法。不变性直到最近才获得动力。Hillel Wayne 还解释了如何完全消除并发性有助于消除可变性带来的痛苦。

但我更愿意从不同的角度来解决这个问题:可变性代表了一个架构问题,它对每个编程语言或环境都是横向的,无论是多线程还是单线程都无关紧要。

通过思考架构,很容易意识到可变性如何导致不可预测的软件。是否有任何东西可以保证在某些条件下该对象将处于确定状态?没有。有多少个实体可以导致给定对象的状态发生变化?这些变化能得到控制吗?不是真的,想想主作用域中的变量......从字面上看,任何事情都可能影响其状态,并且像“每个操作都应该考虑 [...]”这样的假设是不安全的,而且非常容易出错。

因此,虽然可变性不一定是错的,但不可变性是开发人员工具包中的另一个工具,掌握它会让你成为更好的开发人员。

不可变数据结构(不可更改对象)只支持一个操作:读取,这使得你的程序表现得像摩尔机:给定一个特定的时刻,你的程序将始终处于其可能的状态之一

您的程序是始终可以计算和测量的操作管道:

R.pipe(
  R.toUpper, // You know the state of your program here
  R.concat(R.__, '!!'), // or here
)('Hello World'); // or here

您还可以将其其中一个阶段与其返回的值交换,并且仍然使程序按预期运行:

R.pipe(
  R.always('HELLO WORLD'),
  R.concat(R.__, '!!'), 
)('Hello World');

不可变性还支持时间旅行,使测试变得非常容易,但真正重要的是,它使推理状态及其转换变得非常容易,因为你把每个值都当作一个原始值:变得与 没有什么不同。user.set('name', 'Giuseppe')'Giuseppe'.toUpperCase()

随着时间推移,您的程序最终是一系列确定的快照:

-> 'Hello World' -> 'HELLO WORLD' -> 'HELLO WORLD!!'
t(0) ----------- t(1) ----------- t(2) ------------- t(n)

注意:虽然你有更多的中间值,但不可变性也会给你带来性能提升,因为它使深度平等变得毫无意义。

const user = { name: 'Giuseppe' };
const equals = (given, expected) => given === expected;

const newUser = { ...user, name: 'Marco' };

console.log('are users equals:', equals(user, newUser));

你需要一个 deepEqual 函数来获得具有可变性的相同结果......(在 Redux 网站上阅读更多内容)

评论

0赞 11/5/2019
问题是,比较参考文献会破坏参考文献的透明度,这种影响也可能是有害的。我认为散布在代码中的大量相等性检查可能表明了一种错误或至少可疑的方法,就像 React 的“差异”算法一样。他们应该进行增量更新,而不是使用差异树。无论如何,感谢您的努力!
3赞 Bergi 11/5/2019 #3

JS 通过事件循环对并发进行建模。因此,没有竞争条件。

让我们就此打住。您可能不会得到两个不同的线程尝试同时访问相同的内存位置,但您仍然可以让程序的并发部分异步访问可变状态,并忽略它们并不孤单的事实。这仍然是一个竞争条件。

一个简单的例子:

var clock = out.value = 0;

async function incrementSlowly() {
  if (clock == 12)
    clock = 0; // reset
  await delay(1000);
  clock++;
  out.value = clock;
}
function delay(t) { return new Promise(resolve => setTimeout(resolve, t)); }
<output id="out"></output>
<button onclick="incrementSlowly()">Tick!</button>

该值永远不会大于 12?试试自己快速按下按钮时会发生什么。clock

函数的多次调用是独立运行的,并且在错误的时间执行检查 - 在延迟期间,另一个实例可能已经再次递增了 。incrementSlowlyclock

在此示例中,我使用了可变变量,但使用可变数据结构也是如此。当有多个代理通过不同的方法访问结构时,它并不总是那么明显。

使用不可变的数据结构会强制你有状态操作显式化,并且很明显,实际上访问状态两次。incrementSlowly

评论

1赞 11/5/2019
我认为你的例子揭示了该类型的另一个缺陷。查看完整回复Promise
0赞 Aadit M Shah 11/5/2019
@bob 将文件重命名为 。Markdown 支持代码片段。因此,它应该看起来与您的原始答案完全一样。race-conditions.md
0赞 Bergi 11/5/2019
@bob 这与承诺本身无关,如果你直接使用,也会发生同样的情况。它比嵌套回调更容易被遗漏,更好地隐藏了异步:-)setTimeoutawait
1赞 Aadit M Shah 11/5/2019
@bob 发布了关于您的要点的解释。
1赞 11/5/2019
@AaditMShah要点评论的要点:我的陈述是错误的。如果我们有并发性和可变性,我们就有了竞争条件。异步计算是事件循环的并发机制。因此,可能存在与可变数据相关的竞争条件。