Andrey on .NET | Отложенные действия в C#

Отложенные действия в C#

Часто изменение объекта ведет к необходимости вызова метода для обработки новых или отредактированных данных. Например, после добавления записи необходимо вызывать метод сортировки. В этом случае, при добавлении серии получится множество ненужных вызовов. Логичным решением будет отложить сортировку до окончания изменений. Как лучше это сделать? Рассмотрим один из вариантов решения данной проблемы.

Данная реализация была написана после прочтения заметки "Postponing actions" в блоге Эмиля Джонгериуса (Emiel Jongerius). Отличия, из-за которых я написал свой вариант, указаны в конце текста.

Предлагаемая реализация отложенного действия использует конструкцию using для определения блока кода, где выполнение определенного метода может быть отложено. Вызовы этого метода внутри блока будут откладываться до момента выхода из него. Вне блока – выполняться немедленно. Кроме этого:

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

Перейдем к коду. Конструкция using требует реализации IDisposable. Для этой цели создан вспомогательный вложенный класс DisposableInterfaceHelper. При выходе из using он вызовет переданный в конструкторе метод.

/// <summary>Nested class. Perform an action when the instance is disposed.</summary>
private class DisposableInterfaceHelper : IDisposable
{
    /// <summary>Postponed action helper.</summary>
    private Action _onDispose;

    /// <summary>Initializes a new instance of the DisposableInterfaceHelper class.</summary>
    /// <param name="onDispose">The action to be called on dispose.</param>
    public DisposableInterfaceHelper(Action onDispose)
    {
        if (onDispose == null) {
            throw new ArgumentNullException("onDispose");
        }

        this._onDispose = onDispose;
    }

    /// <summary>IDisposable implementation: Execute action on dispose.</summary>
    void IDisposable.Dispose()
    {
        this._onDispose();
    }
}

Логика отложенного действия реализована в классе PostponableAction. Она учитывает входы и выходы из блоков using. Это определяет поведение при запросе на выполнение откладываемого метода. Основные методы и свойства PostponableAction:

  • public PostponableAction(Action sourceAction) – конструктор. Получает в качестве параметра откладываемый метод.
  • public void Execute() – запрос на выполнение отложенного метода. Вне блока using приведет к немедленному его выполнению.
  • public IDisposable PostponeAction() – метод, используемый для конструкции using.
public class PostponableAction
{
    #region Private fields

    /// <summary>IDisposable interface helper object.</summary>
    private readonly DisposableInterfaceHelper _disposableInterfaceHelper;

    /// <summary>The action to be postponed.</summary>
    private Action _postponeAction;

    /// <summary>Postpone level counter.</summary>
    private int _postponeLevel = 0;

    /// <summary>Controls postponed action execution.</summary>
    private bool _mustExecute = false;

    /// <summary>Lock object (for thread safety).</summary>
    private object _lockObject = new object();

    #endregion

    /// <summary>Initializes a new instance of the PostponableAction class.</summary>
    /// <param name="sourceAction">The action to be postponed.</param>
    public PostponableAction(Action sourceAction)
    {
        if (sourceAction == null) {
            throw new ArgumentNullException("sourceAction");
        }

        this._disposableInterfaceHelper = new DisposableInterfaceHelper(this.OnDisposeHelper);
        this._postponeAction = sourceAction;
    }

#if DEBUG
    /// <summary>Finalizes an instance of the PostponableAction class. For debug only.</summary>
    ~PostponableAction()
    {
        if (this._postponeLevel != 0) {
            throw new InvalidOperationException("Postponed action planned but not executed.");
        }
    }
#endif

    #region Properties

    /// <summary>Gets a value indicating whether postponing is active.</summary>
    public bool IsPostponing
    {
        get { return this._postponeLevel > 0; }
    }

    #endregion

    /// <summary>Returns IDisposable interface. 
    /// This allow using the 'using' keyword to define where action will be postponed.</summary>
    /// <returns>IDisposable interface.</returns>
    public IDisposable PostponeAction()
    {
        lock (this._lockObject) {
            this._postponeLevel++;
        }

        return this._disposableInterfaceHelper;
    }

    /// <summary>Initiates the action execution. 
    /// If postponing is not active then action will be executed immediately.</summary>
    public void Execute()
    {
        lock (this._lockObject) {
            if (this._postponeLevel > 0) {
                this._mustExecute = true;
                return;
            }
        }

        this._postponeAction();
    }

    /// <summary>Executes postponed action if required.
    /// This method will be called from DisposableInterfaceHelper on dispose.</summary>
    private void OnDisposeHelper()
    {
        lock (this._lockObject) {
            this._postponeLevel--;
            if ((this._postponeLevel > 0) || !this._mustExecute) {
                return;
            }

            this._mustExecute = false;
        }

        this._postponeAction();
    }
}

Рассмотрим использование отложенного действия на примере метода Sort():

  1. создаем private метод SortImplementation() и переносим туда исходную реализацию;
  2. добавляем переменную PostponableAction _postponableSort, которая будет связана с SortImplementation();
  3. исходный метод Sort() теперь просто вызывает PostponableAction.Execute();
  4. для удобства определяем метод PostponeSort, которое будет использоваться для блоков using.

Подробности хорошо видны в исходном коде примера:

public class TestObject
{
    public TestObject()
    {
        this._postponableSort = new PostponableAction(this.SortImplementation);
    }

    #region Postponable Sort

    private PostponableAction _postponableSort;

    private void SortImplementation()
    {
        Console.WriteLine("Sort > sorting ...");
    }

    public void Sort()
    {
        this._postponableSort.Execute();
    }

    public IDisposable PostponeSort()
    {
        return this._postponableSort.PostponeAction();
    }

    #endregion

    public void Operation1()
    {
        // Do something ...
        Console.WriteLine("Operation1");

        this.Sort();
    }

    public void Operation2()
    {
        // Do something ...
        Console.WriteLine("Operation2");

        this.Sort();
    }

    public void Operation3()
    {
        Console.WriteLine("Operation3 > starting ... ");

        using (this.PostponeSort()) {
            this.Operation1();
            this.Operation2();
        }

        Console.WriteLine("Operation3 > completed.");
    }
}

В методе Operation3() реальный вызов метода сортировки произойдет только при выходе из блока using. Так же можно использовать отложенный вызов из кода вне класса:

using (testObject.PostponeSort()) {
    testObject.Operation1();
    testObject.Operation2();
    testObject.Operation3();
}

Дополнительно в приложенном исходном коде я оставил реализацию без использования вспомогательного класса - PostponableActionUnsafe. Она компактнее, но имеет недостаток – возможность некорректного использования. Причина заключается в прямом наследовании от интерфейса IDisposable. Это позволяет вместо

public IDisposable PostponeSort { get { return this._postponableSort.PostponeAction(); } }

написать

public IDisposable PostponeSort { get { return this._postponableSort; } }

, что не вызовет ошибку на этапе компиляции. Но при этом вход в блок using не будет зафиксирован. Ситуация отслеживается только в run-time и вызывает исключение InvalidOperationException при выходе из блока.

В завершении отмечу отличия от реализации Эмиля Джонгериуса:

  1. Отсутствие вызова отложенного метода в финализаторе вспомогательного класса (и, как следствие, проще его код). Причина в неопределенности момента вызова финализатора.
  2. Используется только один экземпляр вспомогательного класса (вместо создания отдельного экземпляра для каждого блока using).
  3. Вариант без использования вспомогательного класса.

Исходный код (C#, Visual Studio 2010):
PostponableActionDemo.zip (14.40 kb)

Комментарии (14) -

Довольно интересное направление, некое подобие Lazy.
У Вас был реальный пример из практики, где удалось применить этот подход?

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

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

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

Идея интересная, спасибо. Но не могу не сделать пару замечаний Smile

1. Использование свойств, которые изменяют состояние объекта - плохая практика. Очень легко посмотреть на свойства объекта в дебаггере, а потом гадать, почему не вызвалась сортировка, да еще и финализатор кинул исключение Smile Или просто внутри using обратиться к этому свойству еще раз. С учетом названия PostponeAction (используется глагол), лучше сделать метод.

2. Возвращение одного экземпляра вспомогательного класса противоречит best-practices for IDisposable implementation. Дело в том, что рекомендуется реализовывать IDisposable так, чтобы он не зависел от количества вызовов Dispose, а именно освобождать ресурсы только при первом вызове. Если внутри блока using явно вызвать Dispose, то не должно ничего ломаться. Ваш пример в таком случае работать не будет.

Чуть-чуть в другом порядке отвечу:

1. В принципе  согласен по финализатору, что место ошибки не покажет и выскочит когда попало. Но по сути это как флаг, что где-то не правильно используется класс. А уже как и где – придется искать. Поэтому он и с пометкой только для отладки (и то сомневался и хотел его выкинуть).

2. Не согласен по свойству PostponeAction, хотя и в метод переделать не сложно. Но мне кажется в данной ситуации это дело вкуса. Равно как и про один экземпляр объекта. Причина – IDisposable не используется для управления unmanaged ресурсами. Он нужен чтобы красиво завернуть часть кода в using. Собственно, этого же можно добиться методами, отмечающими начало и конец блока. Но тогда уже получится не так удобно в использовании. Да и возможностей ошибиться добавиться.

Плюс, я (пока что ? ;) ) не представляю сценарий с вызовом Dispose внутри блока в контексте данного класса. Причем, если следовать указанным советам, то для остальных данных отложенный метод вызван не будет. Правда немного подстраховался – хоть и не отметил это в отличиях. Если вы заметили, то там явная  (explicit) реализация void IDisposable.Dispose(). Случайно уже не вызвать. Smile В этом контексте есть идея добавить принудительный запуск метода, но пока не решил как лучше. Да и пока необходимости нет.

Хоть IDisposable и не используется для управления unmanaged ресурсами, но он используется для вызова кода, который является критичным для корректности программы. Если я вдруг напишу так:

using (testObject.PostponeSort)
{
  var temp = testObject.PostponeSort; // Не знаю зачем, но хочу. Использование свойств располагает к такому использованию.
  testObject.Operation();
}

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

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

Про удобство IDisposable - согласен. Но все-же считаю, что надо следовать правилам, которые придумали умные дяди из MS. Потому что публичный контракт - есть публичный контракт, и он должен быть реализован так, как описан, и никак иначе.

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

Но вот по поводу использования var temp = testObject.PostponeSort; // Не знаю зачем, но хочу. не согласен. С такой мотивацией можно много чего в коде написать. Smile Просто заменой одного экземпляра на создание каждый раз нового тут не обойдешься. Т.к., продолжая такую логику, в этом случае нет гарантии вызова Dispose. Значит нужен финализатор. И опять приходим к тому, что откладываемый метод может быть вызван неизвестно когда, а значит и неизвестно зачем.

В принципе, есть мысль, что можно вынести всю логику вызова метода во вложенный класс и тогда уже порождать для каждого блока свой экземпляр. Получится, что каждый using будет "самодостаточным". Правда появится отличие в поведении - при вложенных using, метод будет вызываться после каждого блока. Но вот пока нет такой задачи, где бы это было надо. Но при необходимости можно сделать и такую реализацию.

msdn.microsoft.com/en-us/library/ms229054.aspx
Do use a method, rather than a property ... if the operation has a significant and observable side effect

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

"Т.к., продолжая такую логику, в этом случае нет гарантии вызова Dispose"
Клиент и не должен давать такой гарантии. Когда клиент видит свойство, он может рассчитывать на отсутствие сайд-эффектов. Когда клиент получает IDisposable из свойства, он имеет право считать, что ему каждый раз вернется один и тот же экземпляр. Из документации метода Dispose следует, что достаточно вызвать его один раз, чтобы освободить ресурсы. Поэтому клиент не обязан вызывать Dicpose 2 раза. Мой пример совершенно корректен, если абстрагироваться от конкретного случая.

Если же вместо свойства будет метод, то клиент ОБЯЗАН ожидать, что каждый вызов метода будет возвращать новый экземпляр IDisposable. И тогда мой пример будет некорректен, потому что клиент ОБЯЗАН вызвать Dispose у второго экземпляра. Но при этом библиотека не обязана возвращать разные экземпляры - вполне можно использовать подход со счетчиком, описанный в статье.

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

Согласен. Smile Я собственно и в прошлом ответе не был согласен только с вызовом методов "а вот незнаю зачем но хочу".

Интересный подход. Спасибо за пост.
Вопрос, что будет при возникновении исключения в блоке using? Выполнять action по хорошему уже не надо, однако он будет выполнен. Это может привести к ошибке, так как блок using был прерван исключением.

Абсолютно верно, отложенный метод будет выполнен. А вот насколько это будет корректно?

Рассмотрим на примере. Пусть будет та же сортировка, как в статье. Сравним с ситуацией без отложенного вызова. В этом случае метод будет вызываться после каждого изменения данных. Т.е. все равно он будет вызван для предпоследнего набора данных (считаем на последнем у нас исключение). Таким образом, если состояние объекта не становится некорректным, то особых проблем (по сравнению с вариантом без отложенного действия) я не вижу. Если ситуация, давшая исключение, приводит объект в некорректное состояние, то остается проверять корректность в самом методе.

Отдельно можно подумать над вариантом когда объект остается корректным, но откладываемая операция очень дорогостоящая. Или же логика приложения требует отмены запуска. В этом случае в OnDisposeHelper() / IDisposable.Dispose() можно определить было ли исключение и отменить запуск принудительно.

Я тоже придерживаюсь мнения, что следует использовать IDisposable в том виде, в котором его рекомендуют в Microsoft. Если кто-то кроме вас увидит код он может быть удивлен реакцией компонентов. Для таких вещей опять же есть устоявшаяся практика BeginOperationName, EndOperationName

Кстати, вот утверждение, о том что такие использование предусматривалось Smile

blogs.msdn.com/.../100872.aspx

в этом был бы смысл, если бы они назвали интерфейс, обязательный в этом паттерне, не IDisposable как то более соответствующе.  понятно, что это формальность, но такие вещи могут вводить заблуждение о том, что конкретно происходит во время выполнения. написать
try{
BeginUpdate();
}finnaly{
EndUpdate();
} не намного сложней, а читаемость кода выше.

Для начала отмечу, что ссылка была на блог одно из разработчиков языка C#.

Что касается варианта try-finnaly, то IMHO вот как раз лишние строки в этом случае снижают читаемость, захламляя код.

Поэтому у меня вопрос - что именно может ввести в заблуждение программиста в таком использовании using? Сразу обращаю внимание, что мы рассматриваем исходный код программы, а не документацию MSDN.

Добавить комментарий