10

.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 类的情况下,这代表了一个试图做太多事情的类/接口,应该分成更基本的部分。缺乏严格的文档化合同使得测试变得不可能,实施变得更加困难!

4

6 回答 6

4

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

不幸的是,MSDN 没有给出任何关于CanRead/ CanWrite/CanSeek可能随时间变化的具体保证。我认为可以合理地假设,如果一个流是可读的,它将继续可读,直到它关闭 - 其他属性也是如此

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

这应该照顾除最病态的病例之外的所有病例。(流几乎旨在造成破坏!)将这些要求添加到现有文档中在理论上是一个突破性的变化,尽管我怀疑 99.9% 的实现已经遵守它。不过,在Connect上可能值得建议。

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

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

如果它确实允许这样做,它可能是合理的 - 但如果没有它,你最终会出现潜在接口的爆炸式增长:

IReadable
IWritable
ISeekable
IReadWritable
IReadSeekable
IWriteSeekable
IReadWriteSeekable

我认为这比目前的情况更混乱——尽管我认为我支持在现有班级之外进行公正IReadable和补充的想法。这将使客户更容易以声明方式表达他们的需求。IWritableStream

诚然,使用Code Contracts,API可以声明它们提供的内容和需要的内容:

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

    ...
}

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

    ...
}

我不知道静态检查器对此有多大帮助 - 或者它如何应对流在关闭时确实变得不可读/不可写的事实。

于 2009-08-01T11:22:40.227 回答
3

在不了解对象内部结构的情况下,您必须假设“标志”属性太不稳定,无法在多个线程中修改对象时依赖。

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

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

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

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

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

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

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

于 2009-07-31T13:10:19.427 回答
1

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

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

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

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

于 2009-07-31T06:22:36.873 回答
1

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

您指出的是,文档中缺少一些东西,迫使人们对合同做出假设。更具体地说,因为没有说明流的可读性属性的波动性,所以可以做出的唯一假设是有可能抛出a NotSupportedException,而不管相应的 CanRead 属性的值是多少毫秒(或更多)之前。

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

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

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

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

于 2009-07-31T11:22:52.570 回答
1

我也很欣赏有关此模式有效性的评论(CanXXX、XXX() 对)。

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

  1. 参数CanXXX成员将始终返回相同的值,除非……</p>

  2. …在存在CanXXXChanged事件的情况下,无参数CanXXX可能会在该事件发生之前和之后返回不同的值;但它不会在不触发事件的情况下改变。

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

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

  4. XXX(…)只有CanXXX返回时调用才能成功true


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

于 2013-05-18T18:55:11.190 回答
0

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

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

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

编辑:解决 CanRead 等是否有用的问题,我相信它们仍然有用 - 主要用于论证验证。例如,仅仅因为一个方法需要在某个时刻想要读取的流并不意味着它想要在方法开始时立即读取它,但理想情况下应该执行参数验证。这实际上与检查参数是否为 null 并抛出而不是在您第一次碰巧取消引用时ArgumentNullException等待抛出 a 没有什么不同。NullReferenceException

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

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


好吧,让我们试着换一种方式……

除非您在内存中读取/查找,并且您已经确保有足够的数据,或者您正在预先分配的缓冲区中写入,否则总是有可能出现问题。磁盘故障或填满,网络崩溃等。这些事情确实发生在现实生活中,所以你总是需要以一种能够在故障中幸免于难的方式进行编码(或者在问题并不重要时有意识地选择忽略问题)。

如果您的代码在磁盘故障的情况下可以做正确的事情,那么它就有可能在FileStream从可写变为不可写的转变中幸存下来。

如果Stream确实有固定合同,那么它们就必须非常弱——你不能使用静态检查来证明你的代码将永远有效。你能做的最好的事情就是证明它在面对失败时做了正确的事。

我不相信Stream会很快改变。虽然我当然接受可以更好地记录它,但我不接受它“完全损坏”的想法。如果我们不能在现实生活中实际使用它,它会更坏……如果它可以比现在更坏,从逻辑上讲,它还没有完全坏掉

我的框架有更大的问题,例如日期/时间 API 的状态相对较差。在最近的几个版本中,它们变得更好了,但它们仍然缺少(比如说)Joda Time的很多功能。缺乏内置的不可变集合,语言中对不可变性的支持不佳等 - 这些都是让我真正头疼的真正问题。我宁愿看到它们得到解决,也不愿花时间解决在Stream我看来是一个有点棘手的理论问题,在现实生活中几乎没有问题。

于 2009-07-31T06:46:45.513 回答