问题 .NET流功能 - CanXXX测试安全吗?


.NET中使用了一种相当常见的模式来测试类的功能。这里我将使用Stream类作为示例,但该问题适用于使用此模式的所有类。

该模式是提供一个名为CanXXX的布尔属性,以指示该类上的功能XXX可用。例如,Stream类具有CanRead,CanWrite和CanSeek属性,以指示可以调用Read,Write和Seek方法。如果属性值为false,则调用相应的方法将导致抛出NotSupportedException。

从流类的MSDN文档:

根据底层数据源或存储库,流可能仅支持其中一些功能。应用程序可以使用CanRead,CanWrite和CanSeek属性查询流的功能。

和CanRead属性的文档:

在派生类中重写时,获取一个值,指示当前流是否支持读取。

如果从Stream派生的类不支持读取,则对Read,ReadByte和BeginRead方法的调用会抛出NotSupportedException。

我看到很多代码都是按照以下方式编写的:

if (stream.CanRead)
{
    stream.Read(…)
}

请注意,没有同步代码,例如,以任何方式锁定流对象 - 其他线程可能正在访问它或它引用的对象。也没有捕获NotSupportedException的代码。

MSDN文档未声明属性值不能随时间更改。实际上,当流关闭时,CanSeek属性会更改为false,从而演示这些属性的动态特性。因此,没有合同保证在上面的代码片段中调用Read()不会抛出NotSupportedException。

我希望有很多代码可以解决这个潜在的问题。我想知道那些发现这个问题的人是如何解决它的。这里适合哪些设计模式?

我也很感激这个模式的有效性的评论(CanXXX,XXX()对)。对我来说,至少在Stream类的情况下,这代表了一个试图做太多的类/接口,应该分成更基本的部分。缺乏紧密的,有文件记录的合同使测试变得不可能,实施更加困难!


2544
2017-07-31 06:04


起源

我相信这是对当前有效问题的出色表现。 +1 - Sam Harwell


答案:


在不知道对象内部的情况下,您必须假设“标志”属性太易于在多个线程中修改对象时无法依赖。

我已经看到这个问题更常见于只读集合而不是流,但我觉得这是相同设计模式的另一个例子,并且适用相同的参数。

为了澄清,.NET中的ICollection接口具有属性IsReadOnly,该属性旨在用作指示集合是否支持修改其内容的方法。就像流一样,此属性可以随时更改,并将导致抛出InvalidOperationException或NotSupportedException。

围绕这个问题的讨论通常归结为:

  • 为什么没有IReadOnlyCollection接口呢?
  • 是否NotSupportedException是一个好主意。
  • 具有“模式”与不同具体功能的优缺点。

模式很少是好事,因为你被迫处理不止一套“行为”;有一些可以随时切换模式的东西要糟糕得多,因为你的应用程序现在也必须处理多个“一组”行为。然而,仅仅因为它可能会将某些内容分解为更加谨慎的功能并不一定意味着你总是应该这样做,特别是当它分开时无助于降低手头任务的复杂性。

我个人认为,你必须选择最接近你认为班级消费者会理解的心理模型的模式。如果您是唯一的消费者,请选择您最喜欢的型号。在Stream和ICollection的情况下,我认为对这些进行单一定义更接近于在类似系统中多年开发建立的心理模型。当你谈论流时,你会谈论文件流和内存流,而不是它们是可读还是可写。同样,当你谈论集合时,你很少用“可写性”来引用它们。

我对此的经验法则:总是寻找一种方法将行为分解为更具体的界面,而不是具有“模式”的操作,只要它与简单的心理模型相称。如果很难将单独的行为视为单独的事物,请使用基于模式的模式并对其进行记录 非常 清晰。


3
2017-07-31 13:10



这是一个经过深思熟虑的答案,我同意大部分内容。关于NotSupportedException,我在这里大肆宣传: stackoverflow.com/questions/410719。我要补充的一点是,当群众的心理模型明显存在缺陷时,有影响力的开发者(如Sun和微软)有义务教育未经洗涤的人,并使行业向更好的方向发展。这似乎不是软件开发的趋势,出于某种原因,我们似乎都接受了推向我们的垃圾。 - Daniel Paull
不幸的是,心理模型是遗留系统中最终的向后兼容性要求;我们没有洗过的群众不喜欢不得不重新学习以前工作得很好的东西,即使新的方式是优越的! - Paul Turner
好的,那么如果遗留系统不能很好地工作呢?如果它确实运作得很好,那么就没有新的优越方式。 - Daniel Paull
也许我只是一个不合理的人 - “理性的人适应世界;不合理的人坚持试图让世界适应自己。因此,所有的进步都取决于不合理的人” - 来自'马克西姆为革命者'乔治伯纳德肖。请注意,你正在推动一个人将世界适应其他人 - 这简直就是很奇怪。你对做得更好的厌恶是什么?那些“不喜欢重新学习东西”的伟大的未洗手不应该试图编写软件或在任何其他工程学科工作。 - Daniel Paull
我非常喜欢“让世界适应其他人”这句话。在我正在进行的软件工作中,特别是用户界面,我发现尝试使我的系统适应不熟悉它的人,是主要目标之一。我个人认为,任何产品的用户界面都是该产品最重要的部分,无论是GUI,命令行还是编程API。创建用户不必适应的接口将是该目标的巨大成就。 - Paul Turner


好的,这是另一次尝试,希望比我的其他答案更有用......

遗憾的是,MSDN没有对如何做出任何具体保证 CanRead/CanWrite/CanSeek 可能随着时间而改变。我认为假设如果流是可读的,它将继续可读,直到它被关闭是合理的 - 并且对于其他属性也是如此

在某些情况下,我认为流是合理的 成为 可以在以后查找 - 例如,它可以缓冲它读取的所有内容,直到它到达底层数据的末尾,然后允许在其中进行搜索,让客户端重新读取数据。不过,我认为适配器忽略这种可能性是合理的。

这应该照顾除了最病态的病例。 (Streams几乎被设计为造成严重破坏!)将这些要求添加到现有文档中是理论上的重大变化,即使我怀疑99.9%的实现已经遵守它。不过,它可能值得建议

现在,关于是否使用“基于能力”的API(如 Stream)和基于接口的...我看到的基本问题是.NET不能提供指定变量必须是对多个接口的实现的引用的能力。例如,我不能写:

public static Foo ReadFoo(IReadable & ISeekable stream)
{
}

如果它 没有 允许这样,它可能是合理的 - 但如果没有这个,你最终会爆发出潜在的接口:

IReadable
IWritable
ISeekable
IReadWritable
IReadSeekable
IWriteSeekable
IReadWriteSeekable

我认为这比目前的情况更糟糕 - 虽然我想我  支持公正的想法 IReadable 和 IWritable 除了现有的 Stream 类。这将使客户更容易以声明方式表达他们所需的内容。

代码合同,API 能够 无可否认地声明他们提供了什么以及他们需要什么:

public Stream OpenForReading(string name)
{
    Contract.Ensures(Contract.Result<Stream>().CanRead);

    ...
}

public void ReadFrom(Stream stream)
{
    Contract.Requires(stream.CanRead);

    ...
}

我不知道静态检查器可以帮助多少 - 或者它如何应对流的事实  当它们关闭时变得不可读/不可写。


4
2017-08-01 11:22



+1。关于 Foo ReadFoo(IReadable & ISeekable stream):问题不在于传递 IReadable & ISeekable 流 成 方法;写吧 Foo ReadFoo<TReadSeekable>(TReadSeekable stream) where TReadSeekable : IReadable, ISeekable。问题是如何 返回 来自一个方法的这样一个流: TReadSeekable OpenForReading(string name) where T : IReadable, ISeekable 不会编译。那是你需要的地方 interface IReadSeekable : IReadable, ISeekable。 - stakx
关于仅在一段时间后才能寻找的流:在这种情况下,应该有一个 CanSeekChanged 事件。它的存在会立即向消费者发出信号 CanSeek  可能 随时改变,同时不必重复轮询。 - stakx
或者,在没有的情况下 CanSeekChanged 事件,如果 CanSeek 是一种方法而不是属性,这将是另一个小提示,返回的值可能不是恒定的,即可能随时改变。 (这种解释来自于 .NET Framework设计指南) - stakx


stream.CanRead只检查底层流是否有可能读取。它没有说明实际读数是否可行(例如磁盘错误)。

如果您使用任何* Reader类,则无需捕获NotImplementedException,因为它们都支持读取。只有* Writer会有CanRead = False并抛出该异常。如果您知道流支持读取(例如您使用StreamReader),则恕我直言,无需进行额外检查。

您仍然需要捕获异常,因为读取期间的任何错误都会抛出它们(例如磁盘错误)。

另请注意,未记录为线程安全的任何代码都不是线程安全的。通常静态成员是线程安全的,但实例成员不是 - 但是,需要检查每个类的文档。


1
2017-07-31 06:22



“如果你使用过任何一个,就没有必要捕获NotImplementedException 读者类“(我认为你的意思是NotSupportedExpection)。那个只处理抽象Stream类的框架呢?它唯一知道的就是Stream接口上的契约 - 没有等待,根本没有定义,所以它什么都不知道因此,框架无法正确编写......这绝不是线程安全的问题,而是关于合同*隐含的问题 (因为它显然没有记录)Stream基类接口。 - Daniel Paull


从你的问题和随后的所有评论中,我猜你的问题在于所述合同的清晰度和“正确性”。声明的合同是MSDN在线文档中的内容。

你所指出的是文档中缺少一些东西,迫使人们对合同做出假设。更具体地说,因为没有任何关于流的可读性属性的波动性的说法,所以可以做出的唯一假设是它是 可能 为一个 NotSupportedException 抛出,无论相应的CanRead属性的值是几毫秒(或更多)之前的值。

我认为需要继续下去 意图 在这种情况下,这个接口,即:

  1. 如果您使用多个线程,则所有投注均已关闭;
  2. 直到你在接口上调用可能会改变流状态的东西,你可以安全地假设它的值 CanRead 是不变的。

尽管如此,Read *方法 可能 可能抛出一个 NotSupportedException

相同的参数可以应用于所有其他Can *属性。


1
2017-07-31 11:22



Eric - 你对界面意图的解释非常好,但是我 讨厌 编程中的合同就像法律一样 - 基于开放式解释 感知 意图。想象一下,如果软件开发人员无法实现完全弥补自己的EULA - 您是否会将您的业务置于暗示接口意图的开发人员身上,或者您更愿意拥有强大的数学定义界面?我们大概都可以从ADA那里学到一点,它的前后条件很好看,有些不是。 - Daniel Paull


我也很感激这个模式的有效性的评论(CanXXX,XXX()对)。

当我看到这种模式的实例时,我通常会期望这样:

  1. 一个 参数少 CanXXX 会员将始终返回相同的值,除非......

  2. ......在...的存在 CanXXXChanged 事件,其中一个参数少 CanXXX 可能在该事件发生之前和之后返回不同的值;但如果不触发事件,它就不会改变。

  3. 一个 参数 CanXXX(…) 成员可以为不同的参数返回不同的值;但对于相同的参数,它可能会返回相同的值。那是, CanXXX(constValue) 很可能保持不变。

    我在这里很谨慎:如果 stream.CanWriteToDisk(largeConstObject) 回报 true 现在,假设它将永远返回是合理的 true 在将来?可能不是,所以也许它取决于上下文是否参数化 CanXXX(…) 将为相同的参数返回相同的值。

  4. 打电话给 XXX(…) 只有这样才能成功 CanXXX 回报 true


话虽如此,我同意 Stream使用这种模式有点问题。至少在理论上,如果在实践中可能没那么多。


1
2018-05-18 18:55





这听起来更像是一个理论问题,而不是一个实际问题。我真的不能想到任何流会变得不可读/不可写的情况 其他 而不是因为它被关闭。

可能存在极端情况,但我不希望它们经常出现。我不认为绝大多数代码需要担心这一点。

然而,这是一个有趣的哲学问题。

编辑:解决CanRead等是否有用的问题,我相信它们仍然存在 - 主要用于参数验证。例如,仅仅因为一个方法需要一个它在某个时候想要读取的流并不意味着它想要在方法的开头就读它,但这就是理想情况下应该执行参数验证的地方。这与检查参数是否为null和抛出没有什么不同 ArgumentNullException 而不是等待 NullReferenceException 当你第一次被解除引用时被抛出。

也, CanSeek 略有不同:在某些情况下,您的代码可以很好地处理可搜索和不可搜索的流,但在可搜索的情况下效率更高。

这确实依赖于“可寻找性”等保持一致 - 但正如我所说,这在现实生活中似乎是真的。


好的,我们试着用另一种方式......

除非你在内存中阅读/寻找,并且你已经确定有足够的数据,或者你是在预分配的缓冲区内写的,所以 总是 事情会出错。磁盘发生故障或填满,网络崩溃等等  在现实生活中发生,所以你总是需要以一种能够在失败中存活的方式进行编码(或者当它无关紧要时有意识地选择忽略问题)。

如果您的代码在磁盘发生故障的情况下可以做正确的事情,那么它可能会幸免于难 FileStream 从可写转为不可写。

如果 Stream 确实有合同,他们必须是 令人难以置信 弱 - 您无法使用静态检查来证明您的代码将始终有效。你能做的最好的事情就是证明它在失败时做的是正确的。

我不相信 Stream 随时都会改变。虽然我当然接受它可以更好地记录,但我不接受它被“彻底打破”的想法。这将是 更多 如果我们真的无法在现实生活中使用它......如果它可能比现在更加破碎,那么它在逻辑上并非如此 全然 破碎。

我对框架有更大的问题,例如相对较差的日期/时间API状态。他们已成为一个 批量 在过去几个版本中更好,但它们仍然缺少很多功能(例如) 乔达时间。缺乏内置的不可变集合,对语言不变性的支持不足等等 - 这些都是导致我的真正问题 实际 头痛。我宁愿看到他们解决,也不愿花时间 Stream 在我看来,这是一个有点棘手的理论问题,在现实生活中几乎没有问题。


0
2017-07-31 06:46



(添加到你说的话。)我相信问题变成:“我们应该使用CanXXX属性吗?”如果操作正在读取流并且它失败,则必须抛出异常。这不像是 TryParse 方法。 - Sam Harwell
将编辑以解决此问题。 - Jon Skeet
如果用“理论”来表示“数学上正确”,那么这是一个理论问题。对我而言,这是非常重要的 - 如果微软无法在抽象类中记录合同,那么每个程序都有人反对它吗?你不知道你可以做出什么样的假设,甚至更糟糕的是,他们做出的假设是什么。我们都注定要失败吗? - Daniel Paull
我的意思是“理论上的”是“是的,一个流 可以 改变它是否可读而不被关闭,但它不会发生在现实生活中。“是的,有很多接口和抽象类没有设计得那么好,不幸的是。 - Jon Skeet
但是,Jon,接口也被使用,以便其他人可以实现它们。如果我使用的是其他人写的流类,那么所有的赌注都不在合同中明确规定的范围内。 - Jesse Pepper