Творческое использование методов расширения в C#

Автор оригинала: Alexey Golub
  • Перевод
Привет, Хабр!

Продолжая исследование темы C#, мы перевели для вас следующую небольшую статью, касающуюся оригинального использования extension methods. Рекомендуем обратить особое внимание на последний раздел, касающийся интерфейсов, а также на профиль автора.



Уверен, что любой, хотя бы немного имевший дело с C#, знает о существовании методов расширений (extension methods). Это приятная фича, позволяющая разработчикам расширять имеющиеся типы новыми методами.

Это исключительно удобно в случаях, когда вы хотите добавить функционал к таким типам, которых не контролируете. Фактически, любому рано или поздно приходилось писать расширение для BCL, просто чтобы сделать некоторые вещи более доступными.

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

Добавление методов к перечислениям

Перечисление – это просто набор константных числовых значений, каждому из которых присвоено уникальное имя. Хотя, перечисления в C# и наследуют от абстрактного класса Enum, они не трактуются как настоящие классы. В частности, это ограничение не позволяет им иметь методы.

В некоторых случаях может быть полезно запрограммировать логику в перечисление. Например, если значение перечисления может существовать в нескольких разных представлениях, и вы хотели бы легко преобразовывать одно в другое.

Например, представьте себе следующий тип в обычном приложении, позволяющем сохранять файлы в различных форматах:

public enum FileFormat
{
    PlainText,
    OfficeWord,
    Markdown
}

Данное перечисление определяет список форматов, поддерживаемых в приложении, и может использоваться в разных частях приложения для инициирования логики ветвления в зависимости от конкретного значения.

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

public static class FileFormatExtensions
{
    public static string GetFileExtension(this FileFormat self)
    {
        if (self == FileFormat.PlainText)
            return "txt";

        if (self == FileFormat.OfficeWord)
            return "docx";

        if (self == FileFormat.Markdown)
            return "md";

        // Будет выброшено, если мы забудем новый формат файла,
        // но забудем добавить соответствующее расширение файла
        throw new ArgumentOutOfRangeException(nameof(self));
    }
}

Что, в свою очередь, позволяет нам поступить так:

var format = FileFormat.Markdown;
var fileExt = format.GetFileExtension(); // "md"
var fileName = $"output.{fileExt}"; // "output.md"

Рефакторинг классов моделей

Бывает, что вы не хотите добавлять метод непосредственно к классу, например, если работаете с анемичной моделью.

Анемичные модели обычно представлены набором публичных неизменяемых свойств, только для получения. Поэтому при добавлении методов к классу модели может создаться впечатление, что нарушается чистота кода, либо можно заподозрить, что методы обращаются к какому-либо приватному состоянию. Методы расширения такой проблемы не вызывают, поскольку не имеют доступа к приватным членам модели и по природе своей не являются частью модели.

Итак, рассмотрим следующий пример с двумя моделями: одна представляет закрытый список титров, а другая – отдельную строку титров:

public class ClosedCaption
{
    // Отображаемый текст
    public string Text { get; }

    // Когда он отображается относительно начала трека 
    public TimeSpan Offset { get; }

    // Как долго текст остается на экране 
    public TimeSpan Duration { get; }

    public ClosedCaption(string text, TimeSpan offset, TimeSpan duration)
    {
        Text = text;
        Offset = offset;
        Duration = duration;
    }
}

public class ClosedCaptionTrack
{
    // Язык, на котором написаны субтитры
    public string Language { get; }

    // Коллекция закрытых надписей
    public IReadOnlyList<ClosedCaption> Captions { get; }

    public ClosedCaptionTrack(string language, IReadOnlyList<ClosedCaption> captions)
    {
        Language = language;
        Captions = captions;
    }
}

В текущем состоянии, если потребуется получить строку субтитров, отображенную в конкретный момент времени, мы запустим LINQ такого рода:

var time = TimeSpan.FromSeconds(67); // 1:07

var caption = track.Captions
    .FirstOrDefault(cc => cc.Offset <= time && cc.Offset + cc.Duration >= time);

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

public static class ClosedCaptionTrackExtensions
{
    public static ClosedCaption GetByTime(this ClosedCaptionTrack self, TimeSpan time) =>
        self.Captions.FirstOrDefault(cc => cc.Offset <= time && cc.Offset + cc.Duration >= time);
}

В данном случае метод расширения позволяет добиться того же, что и обычный, но дает ряд неочевидных бонусов:

  1. Понятно, что этот метод работает только с публичными членами класса и не изменяет его приватного состояния каким-нибудь таинственным образом.
  2. Очевидно, что этот метод просто позволяет срезать угол и предусмотрен здесь только для удобства.
  3. Этот метод относится к совершенно отдельному классу (или даже сборке), назначение которых – отделять данные от логики.

В целом, при использовании подхода с методами расширений удобно проводить линию между необходимым и полезным.

Как сделать интерфейсы разностороннее

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

Если вам это кажется нонсенсом, рассмотрим типичный интерфейс, сохраняющий модель в файл:

public interface IExportService
{
    FileInfo SaveToFile(Model model, string filePath);
}

Все работает нормально, но через пару недель может подоспеть новое требование: классы, реализующие IExportService, должны не только экспортировать в файл, но и уметь писать в файл.

Таким образом, чтобы выполнить это требование, мы добавляем к контракту новый метод:

public interface IExportService
{
    FileInfo SaveToFile(Model model, string filePath);

    byte[] SaveToMemory(Model model);
}

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

Но, чтобы не делать всего этого, мы могли с самого начала спроектировать интерфейс немного иначе:

public interface IExportService
{
    void Save(Model model, Stream output);
}

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

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

К счастью, этот недостаток полностью обнуляется при использовании методов расширений:

public static class ExportServiceExtensions
{
    public static FileInfo SaveToFile(this IExportService self, Model model, string filePath)
    {
        using (var output = File.Create(filePath))
        {
            self.Save(model, output);
            return new FileInfo(filePath);
        }
    }

    public static byte[] SaveToMemory(this IExportService self, Model model)
    {
        using (var output = new MemoryStream())
        {
            self.Save(model, output);
            return output.ToArray();
        }
    }
}

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

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

Похожие публикации

Комментарии 16

    +2

    Спасибо. В закладки!

      –1

      Хм. То есть в С# в последнем примере нельзя просто вернуть stream из функции?
      пс Я джаваскриптер и немного с++

        +4
        Хм. То есть в С# в последнем примере нельзя просто вернуть stream из функции?

        А какой смысл возвращать поток, если вы в него пишете что-то?

          –3
          Тот же смысл что и возвращать строку, а не принимать буфер для записи строки.
            +3

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

              +2

              Понимаете, строка — это что-то, что вы уже аллоцировали. Буфер — это то, что аллоцировал вызывающий, а вы туда записали (предпочтительно, без своей аллокации).


              Аналогично и в случае с потоком: если вы его возвращаете с содержимым, значит, вы выделили на это содержимое память (за исключением того сложного случая, когда вы создаете специальный поток, который умеет читать из вашего объекта), а потом ваш потребитель создает новый поток, куда он будет писать (например, в файл). Зачем тогда вы выделяли память на своей стороне, если вы могли просто принять поток, куда писать?

                –2
                На С++ я могу написать поток, который не аллоцирует никакой памяти, а отдаёт данные в методе чтения, можно перегрузить присваивание и т.д., т.е. семантика значения. С последними нововведениями это проще, но и раньше было возможно.

                Т.е. интерфейс вроде такого
                exportStream.Save(model).pipe(new WriteFileStream("file.txt"))


                С моей точки зрения то же и со строкой — сейчас принято аллоцировать строку и возвращать её, а не писать в принимаемый буфер, хотя под капотом строка сложная.
                  +2
                  На С++ я могу написать поток, который не аллоцирует никакой памяти, а отдаёт данные в методе чтения

                  На C# это тоже можно сделать, но зачем? Это совершенно избыточная сложность. Передать поток намного проще, и это будет идиоматичный код.


                  сейчас принято аллоцировать строку и возвращать её, а не писать в принимаемый буфер

                  А это, кстати, не обязательно так. Есть много интерфейсов (например, JSON-сериализация), которые пишут строку в TextWriter, а не возвращают ее. Ну и да, есть хелперный метод, который создает TextWriter, отдает в сериализатор, потом забирает из него строку.

                    +3
                    На С++ я могу написать поток, который не аллоцирует никакой памяти, а отдаёт данные в методе чтения, можно перегрузить присваивание и т.д., т.е. семантика значения.

                    Проблема в том, что при таком подходе формировать результат придётся асинхронно, даже если сам результат нужен синхронный. То есть понадобится использовать либо конечные автоматы, либо сопрограмму. В обоих случаях возрастает сложность почти на пустом месте. И к тому же резко ограничиваются доступные библиотеки.


                    Впрочем, если асинхронность действительно нужна — решение и на C# есть, надо лишь использовать не потоки, а пайплайны. Возвращаем PipeReader, себе оставляем PipeWriter и в него пишем.

          +2
          Если вам это кажется нонсенсом

          Неудачная калька с английского. Лучше было бы "если вы не понимаете, о идет речь" или что-то в это роде.


          должны не только экспортировать в файл, но и уметь писать в файл.

          Что-то тут не так.


          public static FileInfo SaveToFile(this IExportService self, Model model, string filePath)
              {
                  using (var output = File.Create(filePath))
                  {
                      self.Save(model, output);
                      return new FileInfo(filePath);
                  }
              }

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

            +2
            Если в коде будет вызываться этот метод, замокать его будет практически нереально

            Кстати, да. Я все пытался понять, что меня смущает, спасибо, что вы указали: я как-то привык делать методы-расширения без собственных побочных эффектов, потому что это проще тестировать.

            +2

            Минусы экстеншнов, как и любой статики — фиг замокаешь при написании тестов, стоит помнить об этом.

              –1
              А как же Fody? Если мне не изменякт память, у него есть плагины, позволяющие подменять вызов статических методов.
                +1
                Можно еще harmony использовать (он хотя бы не изменяет il code), но это все равно не то, что хочется делать в тестах.
              +1
              Полагаю, нужно разграничивать цели, для которых можно использовать такой подход.

              Например, для кода с бизнес-логикой extension-методы очень редко используются — их трудно мокать, как уже упомянули выше. Да и в целом такой код иногда нуждается в полиморфизме, то есть переопределении логики для классов-наследников.

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

              Впрочем, и для методов расширения можно реализовать механизмы, которые позволят переопределить логику работы в юнит-тестах, если внутри них обращаться к синглтону, который и содержит логику их работы, и может быть подменён при запуске тестов.
                –1
                «Добавление методов к перечислениям»
                Можно пожалуйста не надо, особенно если это ваше перечисление. Лучше написать класс / структуру с нормальными методами без публичного конструктора и несколькими статическими риоднли членами из этого типа. Работает все точно так же как перечисление но без попыток натянуть сову на глобус.
                Ну и вообще если вам надо добавить какую либо логику в само перечисление, значит вы делаете что-то не так.

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое