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

Отложенная инициализация или «ленивая» инициализация — это способ доступа к объекту, скрывающий за собой механизм, позволяющий отложить создание этого объекта до момента первого обращения. Необходимость ленивой инициализации может возникнуть по разным причинам: начиная от желания снизить нагрузку при старте приложения и заканчивая оптимизацией редко используемого функционала. И действительно, не все функции приложения используются всегда и, тем более, сразу, потому создание объектов, реализующих их, вполне рационально отложить до лучших времён. Я хотел бы рассмотреть варианты ленивой инициализации, доступные в языке 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);
}

На этом всё. Спасибо за внимание.
@arrakisfremen
07.10.2020 05:28 UTC
Первоисточник

Комментарии

@Tantrido
07.10.2020 02:13 UTC
+7

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

@pankraty
07.10.2020 04:37 UTC
+9
lock (_lock)
            {
                return _blob ?? (_blob = new Blob());
            }

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

@AgentRX
07.10.2020 05:02 UTC
+6

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

@Real3L0
12.10.2025 09:03 UTC
0

А в этом случае между проверкой на null и lock'ом параллельный поток не влезет?

12.10.2025 09:10 UTC
0

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

12.10.2025 09:46 UTC
0

А почему не сможет произойти две инициализации?

12.10.2025 11:19 UTC
+1

Смотрите, идея такая:

if (_blob != null)
  return _blob;
lock (_lock)
{
  return _blob ?? (_blob = new Blob());
  
}

До входа в lock мы проверяем, не инициализирован ли уже объект, и если да (а это, мы предполагаем 99+% обращений) - быстро возвращаем этот экземпляр. А если нет - то идём по медленному пути, с заходом в lock; а так как за время нашего ожидания входа в lock кто-то мог успеть инициализировать поле, то внутри мы тоже проверяем на null, чтобы случайно не инициализировать дважды. При таком подходе гонки исключены и ресурсы на тратятся впустую.

12.10.2025 11:44 UTC
0

Вон оно что! Да, так норм, спасибо. Я предполагал такую конструкцию:

return blob ?? lock(_lock) { blob = new Blob() };

@
07.10.2020 06:16 UTC
0
НЛО прилетело и опубликовало эту надпись здесь
@serge-phi
07.10.2020 07:09 UTC
+1
А для чего создается отдельный lock? Почему нельзя сделать lock(this)? C C# работаю не так давно, поэтому это не критика, а запрос информации.
@Guzergus
07.10.2020 07:17 UTC
+7
Для большей безопасности. При локе на this нет гарантий, что кто-то ещё не решит также использовать ваш объект в качестве ключа, что может привести к внезапным дедлокам.
@apcehypo
07.10.2020 15:43 UTC
0

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

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

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

Кстати, чтобы два раза не вставать, еще хуже это lock(typeof(YourType)), так как typeof() возвращает один и тот же экземпляр типа Type независимо от того, откуда он вызван, то контроль над тем кто и когда блокирует ваш код, теряется полностью.
@serge-phi
07.10.2020 21:32 UTC
+1
Спасибо за ответы, я понял суть возможной проблемы. Я не использую долгосрочные блокировки, поэтому про дедлок не подумал.
@shai_hulud
07.10.2020 09:43 UTC
+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;
}
@OrangeCrusty
07.10.2020 13:34 UTC
+6
Ну вообще-то в MSDN есть корректный пример использования в многопоточной среде:
ExpensiveData _data = null;  
bool _dataInitialized = false;  
object _dataLock = new object();  

//  ...  

ExpensiveData dataToUse = LazyInitializer.EnsureInitialized(ref _data, ref _dataInitialized, ref _dataLock);  
@mvv-rus
07.10.2020 12:15 UTC
+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() у лишнего созданного объекта.
@mayorovp
07.10.2020 14:42 UTC
+1

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


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

07.10.2020 16:09 UTC
+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, а по жизни встречается и более старый код).
Но раз уж автор статьи тут решил собрать максимальную коллекцию, как вообще можно делать отложенную инициализацию, то я добавил к ней ещё один вариант.
@buldo
07.10.2020 15:12 UTC
+1

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

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

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

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

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