Незмінні об’єкти, які посилаються один на одного?


77

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

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;
    }
}

Мені цікаво те, що я не можу придумати іншого способу спостерігати за об’єктом типу А у стані, який ще не повністю побудований і включає потоки. Чому це взагалі діє? Чи існують інші способи спостереження за станом об’єкта, який не повністю побудований?


17
Чому ви вважаєте, що він буде недійсним?
леппі

1
Оскільки я розумію, що конструктор повинен гарантувати, що код, який він містить, виконується до того, як зовнішній код зможе спостерігати за станом об'єкта.
Stilgar

1
Код дійсний, але не дуже надійний. Стілгар має рацію - екземпляр класу, Aпереданий B.ctor, не повністю ініційований. Вам потрібно створити новий екземпляр Bin Aпісля того, як екземпляр Aповністю ініціалізований - це повинен бути останній рядок у .ctor.
Карел Фрайтак,

Відповіді:


106

Чому це взагалі діє?

Чому ви вважаєте, що він буде недійсним?

Оскільки конструктор повинен гарантувати, що код, який він містить, виконується до того, як зовнішній код зможе спостерігати за станом об'єкта.

Правильно. Але компілятор не відповідає за підтримку цього інваріанта. Ви є . Якщо ви пишете код, який розбиває цей інваріант, і боляче, коли ви це робите, тоді припиніть це робити .

Чи існують інші способи спостереження за станом об’єкта, який не повністю побудований?

Звичайно. Що стосується типів посилань, усі вони передбачають якимось чином передавати "це" з конструктора, очевидно, оскільки єдиним кодом користувача, який містить посилання на сховище, є конструктор. Деякі способи конструктора можуть просочити "це":

  • Помістіть "це" у статичне поле та посилайтеся на нього з іншого потоку
  • зробити виклик методу або виклик конструктора і передати "це" як аргумент
  • здійснити віртуальний виклик - особливо неприємно, якщо віртуальний метод замінено похідним класом, оскільки тоді він виконується до того, як запуститься тіло ctor похідного класу.

Я сказав, що єдиним кодом користувача, який містить посилання, є ctor, але, звичайно, збирач сміття також містить посилання. Отже, ще один цікавий спосіб, за яким можна спостерігати, що об’єкт перебуває в напівконструйованому стані, це якщо об’єкт має деструктор, і конструктор видає виняток (або отримує асинхронний виняток, наприклад, переривання потоку; про це пізніше. ) У такому випадку об’єкт повинен бути мертвим, і тому його потрібно доопрацювати, але потік фіналізатора може бачити напівініціалізований стан об’єкта. І ось ми знову в користувацькому коді, який може бачити напівконструйований об’єкт!

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

Ще один божевільний спосіб того, як напівконструйований об'єкт може спостерігатися зовнішнім кодом, звичайно, якщо деструктор бачить напівініціалізований об'єкт у сценарії вище, а потім копіює посилання на цей об'єкт у статичне поле, забезпечуючи тим самим, що половина -побудований, напівзавершений об'єкт рятується від смерті. Будь ласка, не роби цього. Як я вже сказав, якщо боляче, не роби цього.

Якщо ви знаходитесь у конструкторі типу значення, то в основному все однаково, але в механізмі є деякі невеликі відмінності. Мова вимагає, щоб виклик конструктора для типу значення створював тимчасову змінну, до якої має доступ лише ctor, мутував цю змінну, а потім виконував структурну копію мутованого значення у фактичне сховище. Це гарантує, що якщо конструктор кидає, остаточне сховище не знаходиться в напівмутованому стані.

Зверніть увагу , що оскільки Struct копія не гарантована атомарним, є можливо для іншого потоку , щоб побачити зберігання в половинному мутували стан; правильно використовуйте замки, якщо ви потрапили в таку ситуацію. Крім того, можливо, щоб асинхронний виняток, як переривання потоку, був перекинутий наполовину через структурну копію. Ці проблеми з неатомністю виникають незалежно від того, чи є копія тимчасовою чи "звичайною" копією. І взагалі, дуже мало інваріантів зберігається, якщо є асинхронні винятки.

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

Для отримання додаткової інформації про те, як працюють конструктори типу значення, див .:

Розвінчання чергового міфу про типи цінностей

Докладніше про те, як семантика мови C # намагається врятувати вас від себе, див .:

Чому ініціалізатори працюють у протилежному порядку як конструктори? Частина перша

Чому ініціалізатори працюють у протилежному порядку як конструктори? Частина друга

Здається, я відхилився від розглянутої теми. У структурі ви, звичайно, можете спостерігати за тим, як об'єкт, який повинен бути побудований наполовину, однаково - скопіюйте напівконструйований об'єкт у статичне поле, викликайте метод з аргументом "this" тощо. (Очевидно, що виклик віртуального методу для більш похідного типу не є проблемою зі структурами.) І, як я вже сказав, копія з тимчасового до кінцевого сховища не є атомною, і тому інший потік може спостерігати напівкопійовану структуру.


А тепер давайте розглянемо першопричину вашого запитання: як ви робите незмінні об’єкти, які посилаються один на одного?

Як правило, як ви виявили, ні. Якщо у вас є два незмінні об'єкти, які посилаються один на одного, то логічно вони утворюють спрямований циклічний графік . Ви можете подумати просто створити незмінний спрямований графік! Зробити це досить просто. Незмінний спрямований графік складається з:

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

Тепер, як ви робите вузли А і В "посиланнями" один на одного:

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

І все готово, у вас є графік, де А і В "посилаються" одне на одного.

Проблема, звичайно, полягає в тому, що ви не можете дістатися до точки В з точки А, не маючи в руках G. Наявність такого додаткового рівня опосередкованості може бути неприйнятною.


Дуже дякую. Я прочитав статтю про тип значення, і вони були однією з причин, чому я думав, що мова намагається гарантувати повну побудову об'єкта, перш ніж його можна буде спостерігати. Зрештою, саме тому значення копіюється навколо.
Stilgar

1
@Stilgar: Ми намагаємось бути мовою "ями якості", де вам дійсно потрібно багато працювати, щоб написати програму, яка робить щось шалене. На жаль, дуже важко розробити корисну мову, якою гарантується, що об’єкт ніколи не буде спостерігатися в нестабільному стані, тому ми не намагаємось це гарантувати . Ми просто намагаємось сильно вас штовхнути в цьому напрямку. (Це в основному те, чому ненульовані типи посилань не працюють у .NET; дуже важко гарантувати в системі типів, що поле ненульованого посилального типу ніколи не буде нульовим.)
Ерік Ліпперт

3
Так, здається, ви зробили настільки гарну роботу, що я очікував, що ви завадите мені написати вищезазначений код.
Stilgar

@Stilgar: Проблема в тому, що якщо ми це зробимо, то ми також заважаємо вам писати багато корисного коду. Іноді дуже корисно мати можливість передавати "це" методу або конструктору іншого класу, особливо в таких типах сценаріїв ініціалізації. Я пишу такий код щодня: у компіляторі ми часто потрапляємо в ситуації, коли незмінні "аналізатори коду" будують незмінні "символи", і вони повинні мати можливість взаємного посилання один на одного.
Eric Lippert,

@EricLippert: Це насправді корисно. У мене є випадки, коли було корисно передавати thisпосилання на інші об'єкти зсередини конструктора. Але чому тоді компілятор не дозволяє використовувати thisключове слово з ініціалізаторів полів? Обидва можуть бачити частково побудовані об'єкти. Це питання насправді не дало підстав для цього обмеження. Якщо ви випадково знаєте причину, будь ласка, поділіться.
Аллон Гуралнек,

47

Так, це єдиний спосіб для двох незмінних об’єктів посилатися один на одного - принаймні один з них повинен бачити інший не повністю побудованим способом.

Це взагалі погана ідея , щоб thisпіти від конструктора , але в тих випадках , коли ви впевнені , що роблять обидва конструктор, і це альтернатива тільки мінливість, я не думаю , що це занадто погано.


2
Я запропонував приклад взаємних посилань, використаних thisу цій відповіді на інше питання.
Брайан

22

"Повністю побудований" визначається вашим кодом, а не мовою.

Це варіація виклику віртуального методу з конструктора,
загальна настанова: не робіть цього .

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


8

Дійсно, витік thisпосилання під час конструктора дозволить вам це зробити; це може спричинити проблеми, якщо методи, очевидно, викликаються на неповному об'єкті. Що стосується "інших способів спостереження за станом об'єкта, який не повністю побудований":

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

1
@Stilgar, якщо це дозволяє мені спостерігати за станом об'єкта, який не повністю побудований, тоді .... meh
Marc Gravell

6

Якщо ви розглядаєте порядок ініціалізації

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

очевидно, що за допомогою висхідного кастингу ви можете отримати доступ до класу ДО виклику конструктора похідного екземпляра (саме тому ви не повинні використовувати віртуальні методи з конструкторів. Вони можуть легко отримати доступ до похідних полів, не ініціалізованих конструктором / конструктором у похідному класі не міг привести похідний клас у "послідовний" стан)


4

Ви можете уникнути проблеми, інстанціруючи B останнім у вашому конструкторі:

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

Якби те, що ви пропонуєте, було неможливим, то А не було б незмінним.

Редагувати: виправлено, завдяки леппі.


Ви пишете для створення екземпляра B останнім у конструкторі, але у прикладі ви ініціюєте його першим, як у коді з OP. Друкарська помилка?
Авада Кедавра

2
Я думаю, що ОП це знає і ставив більш фундаментальне питання.
Henk Holterman

@Nick: Дуже добре, поки у вас є 3 незмінні класи :) `public A () {Name =" test "; B = новий B (цей); C = новий C (цей); } `
VMykyt

3

Принцип у тому, що не дозволяйте цього об’єкту вирватися з тіла конструктора.

Інший спосіб спостерігати таку проблему - це виклик віртуальних методів всередині конструктора.


1

Як зазначалося, компілятор не має можливості знати, в який момент об'єкт був побудований досить добре, щоб бути корисним; тому припускається, що програміст, який переходить thisвід конструктора, буде знати, чи був об'єкт побудований настільки добре, щоб задовольнити його потреби.

Однак я хотів би додати, що для об'єктів, які призначені справді незмінними, потрібно уникати переходу thisдо будь-якого коду, який вивчатиме стан поля до того, як йому буде присвоєно остаточне значення. Це означає, що thisне слід передавати довільний зовнішній код, але не означає, що є щось погане в тому, що об'єкт, що будується, передається іншому об'єкту з метою зберігання зворотного посилання, яке фактично буде використано лише після першого конструктор завершено .

Якби хтось розробляв мову для полегшення побудови та використання незмінних об’єктів, йому може бути корисно оголосити методи, що використовуються лише під час будівництва, лише після будівництва, або будь-якого іншого; поля можуть бути оголошені як такі, що не підлягають перенаправленню під час побудови та лише для читання після цього; Параметри також можуть бути позначені, щоб вказати, які не мають можливості перенаправлення. За такої системи компілятор міг би дозволити побудову структур даних, які посилаються одна на одну, але там, де жодне властивість ніколи не могло змінитися після того, як воно спостерігалося. Щодо того, чи переваги такої статичної перевірки перевищать вартість, я не впевнений, але це може бути цікаво.

До речі, супутньою функцією, яка була б корисною, була б можливість оголосити параметри та повернення функцій як ефемерні, повернуті або (за замовчуванням) постійними. Якщо повернення параметра або функції було оголошено ефемерним, його не вдалося скопіювати в будь-яке поле та передати як стійкий параметр будь-якому методу. Крім того, передача ефемерового чи повертаного значення як поворотного параметра методу призведе до того, що повернене значення функції успадкує обмеження цього значення (якщо функція має два повертаються параметри, її повернене значення успадкує більш обмежувальне обмеження від її параметри). Основною слабкою стороною Java та .net є те, що всі посилання на об’єкти є сумнівними; як тільки зовнішній код потрапляє до одного, вже невідомо, хто може з ним закінчитися. Якщо параметри можна оголосити ефемерними, частіше було можливо, щоб код, який містив єдине посилання на щось, знав, що він містив єдине посилання, і таким чином уникати непотрібних захисних операцій копіювання. Крім того, такі речі, як закриття, можуть бути перероблені, якби компілятор міг знати, що ніяких посилань на них не існувало після їх повернення.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.