Ленивая инициализация в C#

  • Tutorial
Отложенная инициализация или «ленивая» инициализация — это способ доступа к объекту, скрывающий за собой механизм, позволяющий отложить создание этого объекта до момента первого обращения. Необходимость ленивой инициализации может возникнуть по разным причинам: начиная от желания снизить нагрузку при старте приложения и заканчивая оптимизацией редко используемого функционала. И действительно, не все функции приложения используются всегда и, тем более, сразу, потому создание объектов, реализующих их, вполне рационально отложить до лучших времён. Я хотел бы рассмотреть варианты ленивой инициализации, доступные в языке C#.

Для демонстрации примеров я буду использовать класс Test, у которого есть свойство BlobData, возвращающее объект типа Blob, который по легенде создаётся довольно медленно, и было решено создавать его лениво.

class Test
{
    public Blob BlobData
    {
        get
        {
            return new Blob();
        }
    }
}

Проверка на null


Самый простой вариант, доступный с первых версий языка, — это создание неинициализированной переменной и проверка её на null перед возвращением. Если переменная равна null, создаём объект и присваиваем этой переменной, а потом его возвращаем. При повторном обращении объект уже будет создан и мы сразу его вернём.

class Test
{
    private Blob _blob = null;

    public Blob BlobData
    {
        get
        {
            if (_blob == null)
            {
                _blob = new Blob();
            }

            return _blob;
        }
    }
}

Объект типа Blob тут создаётся при первом обращении к свойству. Либо не создаётся, если он по какой-то причине в этой сессии программе не понадобился.

Тернарный оператор ?:


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

class Test
{
    private Blob _blob = null;

    public Blob BlobData
    {
        get
        {
            return _blob == null
                ? _blob = new Blob()
                : _blob;
        }
    }
}

Суть осталась той же. Если объект не инициализирован, инициализируем и возвращаем. Ежели уже инициализирован, то просто сразу возвращаем.

is null


Ситуации бывают разные и мы, например, можем столкнуться с такой, в которой у класса Blob перегружен оператор ==. Для этого, вероятно, нам может потребоваться сделать проверку is null вместо == null. Доступно в свежих версиях языка.

return _blob is null
    ? _blob = new Blob()
    : _blob;

Но это так, небольшое отступление.

Null-coalescing оператор ??


Ещё больше упростить код нам поможет бинарный оператор ??
Суть его работы такова. Если первый операнд не равен null, то он и возвращается. Если же первый операнд равен null, возвращается второй.

class Test
{
    private Blob _blob = null;

    public Blob BlobData
    {
        get
        {
            return _blob ?? (_blob = new Blob());
        }
    }
}

Второй операнд пришлось взять в круглые скобки из-за приоритета операций.

Оператор ??=


В C# 8 появился null-coalescing assignment operator, выглядящий вот так ??=
Принцип его работы заключается в следующем. Если первый операнд не равен null, то он просто возвращается. Если первый операнд равен null, то ему присваивается значение второго и возвращается уже это значение.

class Test
{
    private Blob _blob = null;

    public Blob BlobData
    {
        get
        {
            return _blob ??= new Blob();
        }
    }
}

Это позволило ещё немного сократить код.

Потоки


Если есть вероятность, что к данному ресурсу могут обращаться сразу несколько потоков, нам стоит сделать его потокобезопасным. В противном случае может случиться такая ситуация, что, например, оба потока проверят объект на null, результат окажется false, а затем будет создано два объекта типа Blob, нагрузив систему в два раза больше, чем нам хотелось, и кроме того, один из этих объектов сохранится, а второй будет потерян.

class Test
{
    private readonly object _lock = new object();
    private Blob _blob = null;

    public Blob BlobData
    {
        get
        {
            lock (_lock)
            {
                return _blob ?? (_blob = new Blob());
            }
        }
    }
}

Оператор lock получает взаимоисключающую блокировку заданного объекта перед выполнением определенных операторов, а затем снимает блокировку. Он является эквивалентом использования метода System.Threading.Monitor.Enter(..., ...);

Lazy<T>


В .NET 4.0 появился класс Lazy, позволяющий скрыть всю эту грязную работу от наших глаз. Теперь мы можем оставить только локальную переменную типа Lazy. При обращении к его свойству Value, мы получим объект класса Blob. Если объект был создан ранее, он сразу вернётся, если нет — сначала будет создан.

class Test
{
    private readonly Lazy<Blob> _lazy = new Lazy<Blob>();

    public Blob BlobData
    {
        get
        {
            return _lazy.Value;
        }
    }
}

Так как у класса Blob есть конструктор без параметров, то Lazy сможет создать его в нужный момент без лишних вопросов. Если же нам нужно выполнить какие-то дополнительные действия во время создания объекта Blob, конструктор класса Lazy может принимать ссылку на Func<T>

private Lazy<Blob> _lazy = new Lazy<Blob>(() => new Blob());

Кроме того, во втором параметре конструктора мы можем указать, нужна ли нам потокобезопасность (тот самый lock).

Свойство


Теперь давайте сократим запись readonly свойства, благо современный C# позволяет это делать красиво. В конечном итоге выглядеть всё это станет так:

class Test
{
    private readonly Lazy<Blob> _lazy = new Lazy<Blob>();
    public Blob BlobData => _lazy.Value;
}

LazyInitializer


Ещё есть вариант не оборачивать класс в обёртку Lazy, а вместо этого использовать функционал LazyInitializer. Этот класс имеет один статический метод EnsureInitialized с кучей перегрузок, позволяющих творить всякое, в том числе делать потокобезопасность и писать кастомный код для создания объекта, но основная суть которого заключается в следующем. Проверить, не инициализирован ли объект. Если нет, то инициализировать. Вернуть объект. С использованием данного класса, мы можем переписать наш код так:

class Test
{
    private Blob _blob;
    public Blob BlobData => LazyInitializer.EnsureInitialized(ref _blob);
}

На этом всё. Спасибо за внимание.
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 17

    +7

    Коротко, ясно, понятно! Спасибо!

      +9
      lock (_lock)
                  {
                      return _blob ?? (_blob = new Blob());
                  }

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

        +6

        Double check locking это. Сначала проверяете в read потом уже во write.
        Вместо первого лока можно использовать проверку атомика

        0

        Спасибо! В закладки. Чётко и полезно.

          +1
          А для чего создается отдельный lock? Почему нельзя сделать lock(this)? C C# работаю не так давно, поэтому это не критика, а запрос информации.
            +7
            Для большей безопасности. При локе на this нет гарантий, что кто-то ещё не решит также использовать ваш объект в качестве ключа, что может привести к внезапным дедлокам.
              0

              Потому что на данный объект может сделать такой же лок создатель этого объекта, да и по сути кто угодно. Тогда произойдёт deadlock.

                0
                Дедлок это одна из причин, код класса не контролирует кто блокирует объект извне. Отдельный лок позволяет полностью передать контроль над блокировкой коду класса.

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

                Кстати, чтобы два раза не вставать, еще хуже это lock(typeof(YourType)), так как typeof() возвращает один и тот же экземпляр типа Type независимо от того, откуда он вызван, то контроль над тем кто и когда блокирует ваш код, теряется полностью.
                  +1
                  Спасибо за ответы, я понял суть возможной проблемы. Я не использую долгосрочные блокировки, поэтому про дедлок не подумал.
                  +1
                  LazyInitializer.EnsureInitialized(ref _blob)

                  Не гарантирует единичную инициализацию объекта. Если ломануться 40 потоков, то может получиться 39 инстансов мусора и один победитель.

                  Правильный вариант для многопоточности, который не очень то и удобен:
                  // System.Threading.LazyInitializer
                  private static T EnsureInitializedCore<T>(ref T target, ref bool initialized, ref object syncLock, Func<T> valueFactory)
                  {
                  	object obj = syncLock;
                  	if (obj == null)
                  	{
                  		object obj2 = new object();
                  		obj = Interlocked.CompareExchange(ref syncLock, obj2, null);
                  		if (obj == null)
                  		{
                  			obj = obj2;
                  		}
                  	}
                  	object obj3 = obj;
                  	lock (obj3)
                  	{
                  		if (!Volatile.Read(ref initialized))
                  		{
                  			target = valueFactory();
                  			Volatile.Write(ref initialized, true);
                  		}
                  	}
                  	return target;
                  }
                  
                    +6
                    Ну вообще-то в MSDN есть корректный пример использования в многопоточной среде:
                    ExpensiveData _data = null;  
                    bool _dataInitialized = false;  
                    object _dataLock = new object();  
                    
                    //  ...  
                    
                    ExpensiveData dataToUse = LazyInitializer.EnsureInitialized(ref _data, ref _dataInitialized, ref _dataLock);  
                    
                    +1
                    Если есть вероятность, что к данному ресурсу могут обращаться сразу несколько потоков, нам стоит сделать его потокобезопасным.

                    Оператор lock получает взаимоисключающую блокировку заданного объекта перед выполнением определенных операторов, а затем снимает блокировку.

                    Если допустимо создание нескольких копий ресурса, который надо инициализовать, параллельно в нескольких потоках, то можно обойтись без lock:
                    class Test
                    {
                        private volatile Blob _blob = null;
                    
                        public Blob BlobData
                        {
                            get
                            {
                                  Blob existing_blob = _blob;
                                  if(existing_blob!=null) return existing_blob;
                                  Blob candidate_blob = new Blob();
                                  Interlocked.CompareExchange<Blob>(_blob,candidate_blob,null);
                                  return _blob;
                              }
                        }
                    }
                    

                    Суть кода проста: если ресурс ещё мог не быть создан (ссылка на него — null), то оптимистически создаем новый ресурс и пытаемся сохранить его ссылку атомарной операцией условной записи при условии равенства существующей ссылки значению null.
                    Если не получилось, то созданный нами объект становится добычей сборщика мусора, но в любом случае после этой операции поле ссылки содержит уже инициализованный (нами или кем-то ещё) ресурс — его и возвращаем.
                    Здесь приведен вариант для случая, когда class Blob не реализует IDisposable, иначе нужно модифицировать код так, чтобы он вызывал Dispose() у лишнего созданного объекта.
                      +1

                      У вас в коде два чтения volatile-переменной, но можно и одним обойтись, если использовать Volatile.Read вместо volatile.


                      А ещё лучше — использовать LazyInitializer, который делает примерно то же самое.

                        +1
                        У вас в коде два чтения volatile-переменной, но можно и одним обойтись, если использовать Volatile.Read вместо volatile.

                        И с volatile тоже можно сэкономить одно чтение: сделать две последние строчки до всех закрывающих фигурных скобок такими:
                         existing_blob=Interlocked.CompareExchange<Blob>(_blob,candidate_blob,null);
                         return existing_blob??candidate_blob;
                        

                        Но не вижу особого смысла оптимизировать этот кусок, выполняющийся в очень короткий момент в начале и, чаще всего, ровно один раз.
                        А ещё лучше — использовать LazyInitializer, который делает примерно то же самое.

                        Бесспорно — без своего велосипеда всегда обойтись лучше (правда, не совсем всегда получается: LazyInitializer появился только в Framework 4.0, а по жизни встречается и более старый код).
                        Но раз уж автор статьи тут решил собрать максимальную коллекцию, как вообще можно делать отложенную инициализацию, то я добавил к ней ещё один вариант.
                      +1

                      Так то с Lazy всё не так просто. При создании нужно указать правильные параметры, чтобы не огрести при исключении внутри инициализатора.

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

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

                        Используйте MISRA-подходы даже в энтерпрайз-приложениях. Максимум создавайте статикой. Не плодите объекты, чтобы их сейчас же выбросить. Не бросайте мусор. Дворник не так проворен. Поддерживайте гигиену работы с БД. Отдавайте себе отчет, чего стоит создание объекта класса и вызов его методов. Не прячьте в геттерах положенный в картонную коробку кирпич — кто-нибудь обязательно пнет его ногой. Может даже, по прошествии времени это будете Вы сами ;)
                          0

                          Лично мне было бы интересно почитать про подобные подходы применительно к c#, никто не возьмётся написать статью?

                        Only users with full accounts can post comments. Log in, please.