Возможности потока .NET - это безопасный тест CanXXX?

Существует довольно распространенный шаблон, используемый в .NET для тестирования возможностей класса. Здесь я буду использовать класс Stream в качестве примера, но проблема относится ко всем классам, использующим этот шаблон.

Образец должен предоставить логическое свойство CanXXX, чтобы указать, что в классе имеется возможность XXX. Например, класс Stream имеет свойства CanRead, CanWrite и CanSeek, чтобы указать, что могут быть вызваны методы чтения, записи и поиска. Если значение свойства равно 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, это представляет класс/интерфейс, который пытается сделать слишком много и должен быть разделен на более фундаментальные элементы. Отсутствие жесткого документированного контракта делает тестирование невозможным и выполнение еще сложнее!

Ответ 1

Не зная внутренности объекта, вы должны предположить, что свойство "flag" слишком изменчиво, чтобы полагаться на то, когда объект изменяется в нескольких потоках.

Я видел, что этот вопрос чаще всего спрашивал о коллекциях только для чтения, чем о потоках, но я считаю это еще одним примером одного и того же проекта, и те же аргументы применимы.

Чтобы уточнить, интерфейс ICollection в .NET имеет свойство IsReadOnly, которое предназначено для использования в качестве индикатора того, поддерживает ли коллекция методы для изменения его содержимого. Подобно потокам, это свойство может измениться в любое время и вызовет исключение InvalidOperationException или NotSupportedException.

Обсуждения вокруг этого обычно сводятся к следующему:

  • Почему вместо этого нет интерфейса IReadOnlyCollection?
  • Идея NotSupportedException - хорошая идея.
  • Доводы за и против наличия "режимов" по ​​сравнению с конкретными конкретными функциональными возможностями.

Режимы редко бывают хорошими, поскольку вы вынуждены иметь дело с более чем одним "набором" поведения; наличие чего-то, что может переключать режимы в любое время, значительно хуже, так как ваше приложение теперь имеет дело с более чем одним "набором" поведения. Тем не менее, просто потому, что можно что-то сломать в более сдержанную функциональность, не обязательно означает, что вы всегда должны, особенно при разрыве его, не делает ничего, чтобы уменьшить сложность задачи.

Мое личное мнение заключается в том, что вам нужно выбрать шаблон, который ближе всего к ментальной модели, которую вы воспринимаете, как потребители вашего класса поймут. Если вы единственный потребитель, выберите ту модель, которая вам больше нравится. В случае Stream и ICollection, я думаю, что одно определение их намного ближе к ментальной модели, созданной годами развития в подобных системах. Когда вы говорите о потоках, вы говорите о потоках файлов и потоках памяти, а не о том, читаются они или записываются. Точно так же, когда вы говорите о коллекциях, вы редко ссылаетесь на них с точки зрения "удобочитаемости".

Мое правило на этом: всегда ищите способ разбить поведение на более конкретные интерфейсы, вместо того, чтобы иметь "режимы" работы, если он дополняет простую ментальную модель. Если вам сложно думать о отдельном поведении как о отдельных вещах, используйте шаблон на основе режима и четко обозначите его очень.

Ответ 2

Хорошо, вот еще одна попытка, которая, надеюсь, будет более полезной, чем мой другой ответ...

Несчастливо, что MSDN не дает никаких конкретных гарантий относительно того, как CanRead/CanWrite/CanSeek может со временем меняться. Я думаю, было бы разумно предположить, что если поток читается, он будет оставаться читаемым до тех пор, пока он не будет закрыт - и то же самое будет сохраняться для других свойств

В некоторых случаях я думаю, что было бы разумно, чтобы поток стал доступен для поиска позже - например, он мог бы буферизовать все, что он читал, до тех пор, пока он не достигнет конца базовых данных, а затем разрешить поиск в нем впоследствии, чтобы позволить клиентам перечитать данные. Я думаю, было бы разумным, чтобы адаптер мог игнорировать эту возможность.

Это должно заботиться о всех, кроме самых патологических случаях. (Потоки в значительной степени предназначены для того, чтобы вызвать хаос!). Добавление этих требований к существующей документации является теоретически нарушающим изменением, хотя я подозреваю, что 99,9% реализаций будут его выполнять. Тем не менее, возможно, стоит предложить Connect.

Теперь, что касается обсуждения того, следует ли использовать "основанный на возможностях" 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);

    ...
}

Я не знаю, насколько статичная проверка может помочь с этим - или как она справляется с тем, что потоки становятся нечитаемыми/непримиримыми, когда они закрыты.

Ответ 3

stream.CanRead просто проверяет, имеет ли базовый поток возможность чтения. В нем ничего не говорится о том, возможно ли фактическое чтение (например, ошибка диска).

Нет необходимости перехватывать NotImplementedException, если вы использовали какой-либо из * классов Reader, так как все они поддерживают чтение. Только * Writer будет иметь CanRead = False и выкинуть это исключение. Если вам известно, что поток поддерживает чтение (например, вы использовали StreamReader), IMHO нет необходимости делать дополнительную проверку.

Вам все равно нужно ловить исключения, поскольку любая ошибка во время чтения будет их бросать (например, ошибка диска).

Также обратите внимание, что любой код, который не документирован как потокобезопасный, не является потокобезопасным. Обычно статические члены являются потокобезопасными, но члены экземпляра не являются, однако необходимо проверить документацию для каждого класса.

Ответ 4

Из вашего вопроса и всего последующего комментария, я предполагаю, что ваша проблема заключается в ясности и "правильности" заявленного контракта. Заявленный контракт является тем, что находится в онлайн-документации MSDN.

Вы указали, что в документации есть что-то, что заставляет делать предположения о контракте. Более конкретно, поскольку ничего не сказано о волатильности свойства читаемости потока, единственное предположение, которое можно сделать, состоит в том, что возможно выбросить NotSupportedException, независимо от того, какое значение соответствует соответствующему свойству CanRead было несколько миллисекунд (или более) ранее.

Я думаю, что в этом случае нужно перейти к намерению этого интерфейса, то есть:

  • Если вы используете более одного потока, все ставки отключены;
  • пока вы не назовете что-то на интерфейсе, который потенциально изменит состояние потока, вы можете смело предположить, что значение CanRead является инвариантным.

Несмотря на вышесказанное, методы Read * могут потенциально вызвать NotSupportedException.

Тот же аргумент может быть применен ко всем другим свойствам Can *.

Ответ 5

Я также благодарен за комментарии к достоверности этого шаблона (пары CanXXX, XXX()).

Когда я вижу экземпляр этого шаблона, я обычно ожидал бы этого:

  • Элемент параметр-меньше CanXXX всегда будет возвращать одно и то же значение, если...

  • ... при наличии события CanXXXChanged, где параметр-less CanXXX может возвращать другое значение до и после появления этого события; но он не изменится без запуска события.

  • A параметризованный CanXXX(…) член может возвращать разные значения для разных аргументов; но для тех же аргументов он, вероятно, вернет то же значение. То есть, CanXXX(constValue), вероятно, останется постоянным.

    Я здесь осторожен: если stream.CanWriteToDisk(largeConstObject) возвращает true сейчас, разумно ли предположить, что он всегда будет возвращать true в будущем? Вероятно, нет, поэтому, возможно, это зависит от контекста, будет ли параметр CanXXX(…) возвращать одно и то же значение для тех же аргументов или нет.

  • Вызов XXX(…) может преуспеть, только если CanXXX возвращает true.


При этом я согласен, что использование этого шаблона Stream несколько проблематично. По крайней мере теоретически, если, возможно, не так много на практике.

Ответ 6

Это больше похоже на теоретическую проблему, чем на практическую. Я не могу думать о каких-либо ситуациях, когда поток станет нечитаемым/невоспроизводимым, кроме того, что он закрыт.

Там могут быть угловые случаи, но я не ожидал, что они появятся часто. Я не думаю, что подавляющее большинство кодов должно беспокоиться об этом.

Это интересная философская проблема.

РЕДАКТИРОВАТЬ: Вопрос о том, полезны или нет CanRead и т.д., я считаю, что они все еще есть - в основном для проверки аргументов. Например, только потому, что метод принимает поток, который он хочет прочитать в какой-то момент, не означает, что он хочет прочитать его правильно в начале метода, но в том случае, когда в идеале должна быть проведена проверка правильности аргумента. Это действительно ничем не отличается от проверки того, является ли параметр нулевым и бросает ArgumentNullException вместо того, чтобы ждать, когда NullReferenceException будет брошен, когда вы сначала столкнетесь с его разыменованием.

Кроме того, CanSeek немного отличается: в некоторых случаях ваш код может хорошо справляться как с поисковыми, так и с недоступными для поиска потоками, но с большей эффективностью в поисковом случае.

Это полагается на "поисковую способность" и т.д., оставаясь последовательным - но, как я уже сказал, это кажется истинным в реальной жизни.


Хорошо, попробуем по-другому поступить...

Если вы не читаете/не ищете в памяти, и вы уже убедились, что там достаточно данных или вы пишете в предварительно распределенном буфере, всегда случается, что все пойдет не так. Диски терпят неудачу или заполняются, разрушаются сети и т.д. Эти вещи происходят в реальной жизни, поэтому вам всегда нужно кодировать таким образом, чтобы выдержать неудачу (или сознательно решите игнорировать проблему, когда это не имеет значения).

Если ваш код может сделать что-то в случае сбоя диска, скорее всего, он сможет пережить FileStream переход от перезаписываемого к незаписываемому.

Если у Stream были твердые контракты, они должны были бы быть невероятно слабыми - вы не могли бы использовать статическую проверку, чтобы доказать, что ваш код всегда будет работать. Лучшее, что вы могли бы сделать, это доказать, что он сделал правильную вещь перед лицом неудачи.

Я не верю, что Stream скоро изменится. Хотя я, конечно, согласен с тем, что это может быть лучше документировано, я не согласен с идеей, что он "полностью сломан". Это было бы более сломленным, если бы мы не могли реально использовать его в реальной жизни... и если бы он мог быть более сломанным, чем сейчас, он логически не полностью нарушен.

У меня гораздо больше проблем с каркасом, например, относительно плохое состояние API-интерфейсов даты/времени. Они стали намного лучше в последних двух версиях, но они все еще не хватает многих функций (скажем) Joda Time. Отсутствие встроенных неизменных коллекций, плохая поддержка неизменности на языке и т.д. - это настоящие проблемы, которые вызывают у меня настоящие головные боли. Я бы предпочел, чтобы они были адресованы, чем потратили годы на Stream, что, кажется мне, является некоторой трудноразрешимой теоретической проблемой, которая вызывает несколько проблем в реальной жизни.