Недавно один из читателей задал вопрос по поводу разницы между двумя реализациями синглтонов: через обычное статическое поле или же через статическое поле, содержащее Lazy<T>:
public class FieldLikeSingleton
{
// Вариант C# 6
public static FieldLikeSingleton Instance { get; } = new FieldLikeSingleton();
private FieldLikeSingleton() {}
}
public class FieldLikeLazySingleton
{
private static readonly Lazy<FieldLikeLazySingleton> _instance =
new Lazy<FieldLikeLazySingleton>(() => new FieldLikeLazySingleton());
public static FieldLikeLazySingleton Instance => _instance.Value;
private FieldLikeLazySingleton() {}
}
Для простоты, первую реализацию я буду называть field-like реализацией (*), а вторую – ленивой.
Существует 2 ключевых отличия в поведения этих двух реализаций, о которых нужно знать заранее. Для некоторых классов или приложений разница может оказаться несущественной, но для других она может быть критической.
(*) Название ‘field-like’ уходит корнями в существующую терминологию, как например, field-like events. Да, с C# 6, field-like реализация теперь содержит свойство, но классическая реализация была основана именно на закрытом поле.
Все различия происходят из-за того, что в первом случае тело конструктора синглтона вызывается в статическом конструкторе, а во-втором случае – в статическом конструкторе вызывается лишь конструктор объекта Lazy<T>, а сам объект создается при первом обращении.
1. Время инициализации объекта-синглтона
-
Field-like синглтон будет проинициализирован при вызове статического конструктора (а не только при обращении к свойству Instance).
-
Правила, определяющие время инициализации типа довольно запутаны. Например, при отсутствии явного статического конструктора синглтон может быть проинициализирован перед вызовом метода, в котором он может использоваться (подробнее об этом в статье О синглтонах и статичечских конструкторах).
Вот небольшой пример:
class FieldLikeSingleton
{
public static FieldLikeSingleton Instance { get; } = new FieldLikeSingleton();
private FieldLikeSingleton()
{
Console.WriteLine("FieldLikeSingleton.ctor");
}
public void Foo()
{
Console.WriteLine("Foo");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Inside Main()");
if (args.Length == 42)
{
Singletons.FieldLikeSingleton.Instance.Foo();
}
}
}
При запуске в релизе, на экране нас ждут:
FieldLikeSingleton.ctor
Inside Main()
Поскольку у синглтона нет явного статического конструктора, то правила инициализации типа несколько ослабляются и она происходит до вызова метода. В случае с ленивым синглтоном, мы его проинициализируем непосредственно в момент обращения к свойству Instance.
2. Проблемы с исключениями
- Исключение, возникшее в конструкторе field-like синглтона будет обернуто в TypeLoadException.
- Конструктор field-like синглтона будет вызван только один раз.
- В случае исключение тип синглтона окажется в нерабочем состоянии
Это наиболее ключевое отличие между ленивой и field-like реализацией. В случае field-like синглтона исключения в конструкторе являются по сути фатальными. Когда статический конструктор генерирует исключение, то весь тип помечается невалидным: любое обращение завершается с TypeLoadException (внутри которого будет храниться исходное исключение):
class FieldLikeSingleton
{
public static FieldLikeSingleton Instance { get; } = new FieldLikeSingleton();
private FieldLikeSingleton()
{
throw new DataException("Oops!");
}
public void Foo()
{
Console.WriteLine("Foo");
}
}
class Program
{
static void Main(string[] args)
{
try
{
FieldLikeSingleton.Instance.Foo();
}
catch (TypeLoadException e)
{
// e.InnerException is of type DataException
Console.WriteLine(e.InnerException);
}
Console.WriteLine("Done");
}
}
Исключение, завернутое в TypeLoadException, по сути, становится необрабатываемым. Мы можем перехватить его в высокоуровневом catch(Exception), но там мы точно ничего не сможем с ним сделать. Наш тип является невалидным и любое обращение даже к любому статическому члену приведет к исключению. По сути, исключение в конструкторе такого синглтона должно приводить к прекращению работы приложения, поскольку его нормальная работа невозможна.
Ленивый синглтон этой проблемой не обладает: если конструктор такого синглтона упадет в первый раз, то поле _instance останется непроинициализированным и при следующем обращении к синглтону будет еще одна попытка инициализации.
Так что выбрать?
Понятно, что исключения в конструкторах синглтонах – это дело нехорошее, но ведь далеко не всегда очевидно, может оно произойти или нет. А если произойдет, то будет ли оно восстанавливаемым или временным (transient)? Если это критическое исключение, то тут все просто: в любом случае нужно крэшить приложение, надеясь на то, что в логах будет полное сообщение, а не лишь свойство Message.
Если же исключение является временным (конструктор обращается к файлу или другому ресурсу, который может быть временно недоступен), то ленивый синглтон позволит восстановить работу приложения, а field-like – нет. Это может быть особенно важным в случае сервисов, который сможет восстановить свою работу в одном случае, и не сможет – в другом.
Я практически всегда отдаю предпочтение ленивой версии. Она на одну строку длиннее, но это небольшая цена за более вменяемый стек исключений в случае ошибки в конструкторе. Если же Lazy<T> недоступен (используется более старая версия фреймворка), то для простых типов я использую field-like синглтон, а для сложных – старый добрый double-checking lock.
Дополнительный ссылки
Сергей, я что-то подзабыл.
ОтветитьУдалитьА разве field-like был не более предпочтительным относительно double-checked? Вроде бы в этом блоге и читал, но давно и детали забылись.
Кстати, было бы интересно почитать про какие-нибудь технологии и подходы, которые заставляют взглянуть на процесс разработки по иному.
Конкретно у меня это в первую очередь функциональное программирование (иммутабельность для облегчения дебага, лямбды и т.д.). Туда же хочется отнести IO-монады у хаскелла и схожесть async-ов с ними. Пришло понимание о идее отделения логики с ИО (а асинки практически всегда какое-либо ИО), смена концепции тестирования (моки становятся практически не нужны) и т.д.
Еще, из относительно недавнего могу упомянуть всякие messaging системы типа Service Bus и RabbitMQ. Их можно использовать как для какого-то отложенного исполнения, так и для гарантированного выполнения каких-то нестабильных вещей (очень меня и заказчиков выручило недавно) так и вообще для распараллеливания тяжелых задач по разным нодам, вплоть до подключения этих самых нод на лету.
Хотелось бы почитать что-то такое, что повлияло на тебя. Вплоть до цикла статей :)
Форкну ответы. Это первый по поводу field-like vs. doublec-checked: у первого остается проблема с исключениями. Если они возможны (скажем так, вполне реальны), то лучше double checked.
УдалитьВторая часть: тема эта философская. Я могу подумать над этой темой, чтобы собрать список того, что на меня повлияло, почему и как. Но, как ты написал, у каждого список будет свой. Это не значит, что этим списком будет не интересно делиться (даже наоборот). Так что мысль - ок. Подумаю над ней.
УдалитьЕще есть небольшая разница в случае многопоточного доступа: type initialization в field-like всегда thread-safe, для чего берет глобальный lock, на котором можно получить deadlock (хотя и маловероятно встретить в реальной жизни). В случае с Lazy, многопоточность контролируется явно параметром конструктора Lazy.
ОтветитьУдалитьПо поводу дедлока: в статье по ссылке я как раз рассматривал пример дедлока в реальной жизни:). Так что он возможен:) Хоть и маловероятен.
УдалитьНо да, дополнение годное.
Double-check locking не нужен, есть четвёртый вариант от Джона Скита =)
ОтветитьУдалитьhttp://csharpindepth.com/Articles/General/Singleton.aspx
Четвертый вариант - это тот самый field-like синглтон, который страдает от всех вышеописанных проблем: порядок инициализации, невалидность типа в случае исключения и оборачивание исключения в TypeLoadException.
УдалитьТак что, double check locking нужен, если для вас эти проблемы актуальны.
Четвёртый вариант не замена Lazy. А double-check locking, если мы говорим о том самом double-check locking'е, также не спасёт от TypeLoadException.
УдалитьА вот в пятом варианте есть ещё и ленивость без Lazy.
И, кстати, всегда можно принести Lazy (реализация есть на GitHub) туда где его нет. Так что шестой вариант самый лучший =)
Тот самый double check locking спасает от TypeLoadException при условии, что именно экземплярный конструктор синглтона делает определенную работу. Именно поэтому четвертый вариант - не замена double check locking-у.
УдалитьПо поводу самописного или притянутого Lazy: вариант:)
И да, шестой вариант самый лучший, но очень важно всем понимат - почему;)
Да, тут вы правы.
УдалитьНо не зря у Скита над этим вариантом:
// Bad code! Do not use!
=D
"Ленивый синглтон этой проблемой не обладает: если конструктор такого синглтона упадет в первый раз, то поле _instance останется непроинициализированным и
ОтветитьУдалитьпри следующем обращении к синглтону будет еще одна попытка инициализации."
Конструктор экземплярный или типа? Впрочем что-то не получается восстановить нормальную работу в обоих случаях. Можно пример? Возможно не так понял мысль.
После прочтения казалось, что такой код должен работать...
class Counter
{
public static int Value;
}
class FieldLikeLazySingleton
{
private FieldLikeLazySingleton()
{
if (Counter.Value == 0)
throw new DataException("Oops!");
}
private static readonly Lazy _instance =
new Lazy(() => new FieldLikeLazySingleton());
public static FieldLikeLazySingleton Instance => _instance.Value;
public void Foo()
{
Console.WriteLine("Foo");
}
}
class Program
{
static void Main(string[] args)
{
try
{
FieldLikeLazySingleton.Instance.Foo();
}
catch (DataException)
{
Counter.Value++;
}
FieldLikeLazySingleton.Instance.Foo();
Console.WriteLine("Done");
}
}
И кстати опечатка, вылетает TypeInitializationException при field-like. Ваш пример "Done" не выводит поэтому.
Я тоже думал, что такой код должен работать, но оказался не прав. Спасибо я поправлю опечатку и эту фразу.
УдалитьА поправить? :(
УдалитьLazy кэширует исключения в некоторых случаях: https://docs.microsoft.com/en-us/dotnet/framework/performance/lazy-initialization#exceptions-in-lazy-objects
Там же описаны способы этому помешать. Пример выше будет работать, если поменять на
private static readonly Lazy _instance = new Lazy(() => new FieldLikeLazySingleton(), LazyThreadSafetyMode.PublicationOnly);