Неизменяемые объекты, которые ссылаются друг на друга?

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

public class A
{
    public string Name { get; private set; }
    public B B { get; private set; }
    public A()
    {
        B = new B(this);
        Name = "test";
    }
}

public class B
{
    public A A { get; private set; }
    public B(A a)
    {
        //a.Name is null
        A = a;
    }
}

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

Ответ 1

Почему это даже справедливо?

Почему вы ожидаете его недействительности?

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

Правильно. Но компилятор не несет ответственности за сохранение этого инварианта. Вы. Если вы пишете код, который разбивает этот инвариант, и это больно, когда вы это делаете, перестаньте это делать.

Существуют ли другие способы наблюдения за состоянием объекта, который не полностью сконструирован?

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

  • Поместите "this" в статичное поле и обратитесь к нему из другого потока
  • сделать вызов метода или вызов конструктора и передать "this" в качестве аргумента
  • сделать виртуальный вызов - особенно неприятно, если виртуальный метод переопределяется производным классом, потому что он выполняется до того, как выполняется производное тело ctor.

Я сказал, что единственным кодом пользователя, который содержит ссылку, является ctor, но, конечно, сборщик мусора также содержит ссылку. Таким образом, еще один интересный способ наблюдения объекта в полуконструированном состоянии заключается в том, что объект имеет деструктор, а конструктор генерирует исключение (или получает асинхронное исключение, например, прерывание потока, более подробно об этом позже). ) В этом случае объект должен быть мертвым и, следовательно, должен быть завершен, но поток финализатора может видеть полуинициализированное состояние объекта. И теперь мы вернулись в код пользователя, который может видеть полуконструированный объект!

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

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

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

Обратите внимание, что поскольку копии структуры не гарантируются как атомарные, возможно, что другой поток может видеть хранилище в полумутированном состоянии; используйте блокировки правильно, если вы находитесь в этой ситуации. Кроме того, асинхронное исключение, такое как прерывание потока, может быть выбрано наполовину через копию структуры. Эти проблемы неатомичности возникают независимо от того, является ли копия временной или временной копией. И вообще, очень мало инвариантов поддерживаются, если существуют асинхронные исключения.

На практике компилятор С# оптимизирует временное распределение и копию, если он может определить, что нет способа для возникновения этого сценария. Например, если новое значение инициализирует локальный, который не закрыт лямбдой, а не в блоке итератора, тогда S s = new S(123); непосредственно мутирует s.

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

Разрушение другого мифа о типах значений

И для получения дополнительной информации о том, как семантика языка С# пытается спасти вас от вас, см.:

Почему инициализаторы работают в противоположном порядке как конструкторы? Часть первая

Почему инициализаторы работают в противоположном порядке как конструкторы? Часть вторая

Кажется, я отклонился от темы. В структуре вы можете, конечно, наблюдать, что объект должен быть полуконструирован одинаково - скопируйте полуконструированный объект в статическое поле, вызовите метод с "this" в качестве аргумента и так далее. (Очевидно, вызов виртуального метода для более производного типа не является проблемой для структур.) И, как я уже сказал, копия из временного в конечное хранилище не является атомарным, и поэтому другой поток может наблюдать полукопированную структуру.


Теперь рассмотрим причину вашего вопроса: как вы создаете неизменяемые объекты, которые ссылаются друг на друга?

Обычно, как вы обнаружили, вы этого не делаете. Если у вас есть два неизменяемых объекта, которые ссылаются друг на друга, то логически они образуют ориентированный циклический граф. Вы можете просто построить неизменный ориентированный граф! Делать это довольно легко. Непрерывный направленный граф состоит из:

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

Теперь способ, которым вы создаете узлы A и B "reference" друг друга:

A = new Node("A");
B = new Node("B");
G = Graph.Empty.AddNode(A).AddNode(B).AddEdge(A, B).AddEdge(B, A);

И вы закончили, у вас есть график, где A и B "ссылаются" друг на друга.

Проблема, конечно, в том, что вы не можете добраться до B от A, не имея G в руке. Наличие этого дополнительного уровня косвенности может быть неприемлемым.

Ответ 2

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

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

Ответ 3

"Полностью построенный" определяется вашим кодом, а не языком.

Это вариант вызова виртуального метода из конструктора,
общее руководство: не делайте этого.

Чтобы правильно реализовать понятие "полностью построенный", не пропустите this из своего конструктора.

Ответ 4

Действительно, утечка ссылки this во время конструктора позволит вам это сделать; это может вызвать проблемы, если методы вызваны на неполный объект, очевидно. Что касается "других способов наблюдения за состоянием объекта, который не полностью построен":

  • вызывать метод virtual в конструкторе; конструктор подкласса еще не будет вызываться, поэтому override может попытаться получить доступ к незавершенному состоянию (поля, объявленные или инициализированные в подклассе и т.д.).
  • отражение, возможно, используя FormatterServices.GetUninitializedObject (который создает объект без вызова конструктора вообще)

Ответ 5

Если вы считаете порядок инициализации

  • Производные статические поля
  • Производный статический конструктор
  • Производные поля экземпляров
  • Базовые статические поля
  • Базовый статический конструктор
  • Поля базового экземпляра
  • Конструктор базового экземпляра
  • Производный конструктор экземпляров

ясно, что вы поднимаете кастинг, вы можете получить доступ к классу до того, как вызывается производный экземпляр экземпляра (это причина, по которой вы не должны использовать виртуальные методы от конструкторов. Они могут легко получить доступ к производным полям, не инициализированным конструктором/конструктором в производный класс не мог бы привести производный класс в "согласованное" состояние)

Ответ 6

Вы можете избежать этой проблемы, поставив последнюю в своем constuctor:

 public A() 
    { 
        Name = "test"; 
        B = new B(this); 
    } 

Если то, что вы предлагаете, невозможно, тогда A не будет неизменным.

Изменить: исправлено, благодаря leppie.

Ответ 7

Принцип заключается в том, что не удаляйте этот объект из тела конструктора.

Другим способом наблюдения такой проблемы является вызов виртуальных методов внутри конструктора.

Ответ 8

Как уже отмечалось, компилятор не имеет возможности знать, в какой момент объект был сконструирован достаточно хорошо, чтобы быть полезным; поэтому он предполагает, что программист, который передает this от конструктора, будет знать, был ли объект сконструирован достаточно хорошо, чтобы удовлетворить его потребности.

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

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

Кстати, связанной особенностью, которая была бы полезной, была бы способность объявлять параметры и возвращаемые функции как эфемерные, возвращаемые или (по умолчанию) persistable. Если возврат параметра или функции был объявлен эфемерным, его нельзя было скопировать в какое-либо поле и не передать как устойчивый параметр для любого метода. Кроме того, передача эфемерного или возвращаемого значения в качестве возвращаемого параметра методу приведет к тому, что возвращаемое значение функции наследует ограничения этого значения (если функция имеет два возвращаемых параметра, ее возвращаемое значение наследует более ограничительное ограничение от его параметры). Основная слабость Java и .net заключается в том, что все ссылки на объекты являются беспорядочными; как только внешний код получает свои руки, никто не знает, кто может это сделать. Если параметры могут быть объявлены эфемерными, то чаще всего код может содержать код, который содержит единственную ссылку на то, что он знал, содержал единственную ссылку и, таким образом, избегал ненужных оборонительных операций копирования. Кроме того, такие вещи, как закрытие, могут быть переработаны, если компилятор мог знать, что ссылки после них не существовали после их возврата.