Як мови з типами Maybe замість нулів обробляють крайові умови?


53

Ерік Ліпперт зробив дуже цікавий момент у своїй дискусії про те, чому C # використовує, nullа не Maybe<T>тип :

Послідовність системи типів важлива; чи можемо ми завжди знати, що нерегульована посилання ніколи за жодних обставин не виявляється недійсною? А як щодо конструктора об'єкта з ненульовим полем опорного типу? А як щодо фіналізатора такого об’єкта, де об’єкт доопрацьовується, оскільки код, який повинен був заповнити посилання, кинув виняток? Система типу, яка лежить вам щодо своїх гарантій, небезпечна.

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


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

1
@panzi: Цейлон використовує чутливий до потоку текст, щоб розрізняти Type?(можливо) і Type(не нульово)
Лукас Едер

1
@RobertHarvey Чи не існує кнопки "приємного питання" в Stack Exchange?
користувач253751

2
@panzi Це приємна і допустима оптимізація, але це не допомагає в цій проблемі: коли щось не є Maybe T, цього не повинно бути, Noneотже, ви не можете ініціалізувати його зберігання до нульового покажчика.

@immibis: Я вже підштовхнув це. Тут ми отримуємо дорогоцінні кілька хороших запитань; Я подумав, що цей заслуговує на коментар.
Роберт Харві

Відповіді:


45

Ця цитата вказує на проблему, яка виникає, якщо оголошення та призначення ідентифікаторів (тут: члени екземплярів) відокремлені один від одного. Як швидкий ескіз псевдокоду:

class Broken {
    val foo: Foo  // where Foo and Bar are non-nullable reference types
    val bar: Bar

    Broken() {
        foo = new Foo()
        throw new Exception()
        // this code is never reached, so "bar" is not assigned
        bar = new Bar()
    }

    ~Broken() {
        foo.cleanup()
        bar.cleanup()
    }
}

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

За допомогою нуля деструктор може перевірити, чи була присвоєна змінна if (foo != null) foo.cleanup(). Без нулів об’єкт зараз знаходиться у невизначеному стані - у чому значення bar?

Однак ця проблема існує через поєднання трьох аспектів:

  • Відсутність значень за замовчуванням, таких як nullгарантована ініціалізація змінних членів.
  • Різниця між декларацією та призначенням. Примусово призначати змінні негайно (наприклад, із letзаявою, як це бачимо на функціональних мовах) - це легко примусити гарантувати ініціалізацію - але обмежує мову іншими способами.
  • Специфічний аромат деструкторів як методу, який називається мовою виконання.

Легко вибрати іншу конструкцію, яка не виявляє цих проблем, наприклад, завжди поєднуючи декларацію з призначенням і надаючи мові пропонувати кілька блоків фіналізатора замість одного методу доопрацювання:

// the body of the class *is* the constructor
class Working() {
    val foo: Foo = new Foo()
    FINALIZE { foo.cleanup() }  // block is registered to run when object is destroyed

    throw new Exception()

    // the below code is never reached, so
    //  1. the "bar" variable never enters the scope
    //  2. the second finalizer block is never registered.
    val bar: Bar = new Bar()
    FINALIZE { bar.cleanup() }  // block is registered to run when object is destroyed
}

Тож виникає не проблема з відсутністю нуля, а з поєднанням набору інших ознак із відсутністю нуля.

Зараз цікаве питання, чому C # обрав один дизайн, а не інший. Тут у контексті цитати перераховано безліч інших аргументів щодо нуля мови C #, які можна здебільшого узагальнити як «знайомство та сумісність» - і це є вагомі причини.


Існує також ще одна причина, чому фіналізатор має справу з nulls: порядок доопрацювання не гарантується через можливість еталонних циклів. Але я думаю, що ваша FINALIZEконструкція також вирішує: якщо fooвона вже була доопрацьована, її FINALIZEрозділ просто не працюватиме.
svick

14

Так само, як ви гарантуєте, що будь-які інші дані знаходяться у дійсному стані.

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

Наприклад, в Rust ви створюєте об'єкт типу struct через, Point { x: 1, y: 2 }а не писати конструктор, який це робить self.x = 1; self.y = 2;. Звичайно, це може суперечити стилю мови, який ви пам’ятаєте.

Іншим доповнюючим підходом є використання живого аналізу для запобігання доступу до сховища до його ініціалізації. Це дозволяє оголосити змінну без негайної ініціалізації, доки вона, можливо, призначена до першого читання. Він також може зафіксувати деякі випадки, пов'язані з відмовою, наприклад

Object o;
try {
    call_can_throw();
    o = new Object();
} catch {}
use(o);

Технічно ви також можете визначити довільну ініціалізацію за замовчуванням для об'єктів, наприклад, нуль усіх числових полів, створити порожні масиви для полів масивів тощо, але це досить довільно, менш ефективно, ніж інші параметри, і може маскувати помилки.


7

Ось як це робить Haskell: (не зовсім протилежний твердженням Ліпперта, оскільки Haskell не є об'єктно-орієнтованою мовою).

ПОПЕРЕДЖЕННЯ: відповідь з довгого вітром від серйозного фанату Haskell попереду.

TL; DR

Цей приклад точно ілюструє, чим відрізняється Haskell від C #. Замість делегування логістики побудови конструкції конструктору, вона повинна оброблятися в навколишньому коді. Немає можливості для значення null (або Nothingв Haskell) з'являтись там, де ми очікуємо ненульового значення, оскільки нульові значення можуть виникати лише в межах спеціальних типів обгортки, Maybeякі не є взаємозамінними з / безпосередньо перетворюються на звичайні, неконвертовані мінливі типи. Для того, щоб використовувати значення, яке робиться нульовим, обернувши його в a Maybe, ми повинні спочатку дістати значення, використовуючи відповідність шаблону, що змушує нас перенаправляти контрольний потік у гілку, де ми точно знаємо, що у нас є ненулеве значення.

Тому:

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

Так. Intі Maybe Intє двома абсолютно окремими типами. Знаходження Nothingв рівнині Intбуло б порівняно з пошуком рядка "fish" в an Int32.

А як щодо конструктора об'єкта з ненульовим полем опорного типу?

Не проблема: конструктори значень у Haskell нічого не можуть зробити, окрім як взяти задані їм значення, так і скласти їх. Вся логіка ініціалізації відбувається до виклику конструктора.

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

У Haskell немає фіналізаторів, тому я не можу реально вирішити це питання. Однак моя перша відповідь все ще залишається.

Повний відповідь :

Haskell не має нуля, і використовує Maybeтип даних для представлення зведених змін. Можливо, тип алгабраїчних даних визначений так:

data Maybe a = Just a | Nothing

Для тих, хто вам не знайомий з Haskell, читайте це як "A Maybe- це Nothingабо a Just a". Конкретно:

  • Maybe- конструктор типів : його можна розглядати (неправильно) як загальний клас (де a- змінна типу). Аналогія C # є class Maybe<a>{}.
  • Justє конструктором значень : це функція, яка приймає один аргумент типу aі повертає значення типу, Maybe aяке містить значення. Тож код x = Just 17аналогічний int? x = 17;.
  • Nothingє іншим конструктором значень, але він не бере ніяких аргументів і Maybeповернутий не має значення, окрім "Нічого". x = Nothingє аналогом int? x = null;(якщо припустити, що ми обмежили своє aіснування в Haskell Int, що можна зробити, написавши x = Nothing :: Maybe Int).

Тепер, коли основи Maybeтипу не виходять з ладу, як Haskell уникає питань, обговорених у питанні ОП?

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

По-перше, у Haskell все незмінне . Все. Імена стосуються значень, а не місць пам'яті, де можна зберігати значення (одне лише це величезне джерело усунення помилок). В відміну від C #, де оголошення змінної і присвоювання дві окремі операції, в значеннях Haskell створюються шляхом визначення їх значення (наприклад x = 15, y = "quux", z = Nothing), який ніколи не може змінитися. Тому кодуємо як:

ReferenceType x;

Не можливо в Хаскелл. Немає проблем з ініціалізацією значень, nullоскільки все має бути явно ініціалізовано до значення, щоб воно існувало.

По-друге, Хаскелл - не об’єктно-орієнтована мова : це суто функціональна мова, тому об’єктів у строгому значенні цього слова немає. Натомість є просто функції (конструктори значень), які беруть свої аргументи і повертають об'єднану структуру.

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

do thing 1
add thing 2 to thing 3
do thing 4
if thing 5:
    do thing 6
return thing 7

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

У Хаскеллі це неможливо. Натомість програмний потік повністю диктується ланцюговими функціями. Навіть імперативне вигляд - doпримітка - це лише синтаксичний цукор для передачі анонімних функцій >>=оператору. Усі функції мають форму:

<optional explicit type signature>
functionName arg1 arg2 ... argn = body-expression

Де body-expressionможе бути все, що оцінює значення. Очевидно, що є більше синтаксичних функцій, але головним моментом є повна відсутність послідовностей висловлювань.

Нарешті, і, мабуть, найголовніше, система типів Haskell неймовірно сувора. Якби мені довелося підсумувати центральну філософію дизайну типової системи Хаскелла, я б сказав: "Зробіть якомога більше речей, помиляйтесь під час компіляції, щоб якомога менше помилялось під час виконання". Ніяких неявних перетворень взагалі немає (хочете популяризувати Inta Double? Використовуйте fromIntegralфункцію). Єдине, що, можливо, має недійсне значення, яке виникає під час виконання, - це використання Prelude.undefined(яке, мабуть, просто має бути там і неможливо видалити ).

Зважаючи на все це, давайте подивимось на "зламаний" приклад Амона і спробуємо повторно висловити цей код у Haskell. По-перше, декларація даних (використовуючи синтаксис запису для названих полів):

data NotSoBroken = NotSoBroken {foo :: Foo, bar :: Bar } 

( fooі barсправді є функціями аксесуара для анонімних полів тут, а не фактичних полів, але ми можемо ігнорувати цю деталь).

Конструктор NotSoBrokenзначень нездатний вживати будь-яких дій, окрім прийняття a Fooі a Bar(які не зводяться нанівець) і не NotSoBrokenвиконувати їх. Тут немає місця ставити імперативний код або навіть вручну призначати поля. Вся логіка ініціалізації повинна відбуватися в іншому місці, швидше за все, у спеціально виділеній фабриці.

У прикладі конструкція Brokenзавжди провалюється. Неможливо NotSoBrokenаналогічно розбити конструктор значень (просто немає куди записати код), але ми можемо створити фабричну функцію, яка так само несправна.

makeNotSoBroken :: Foo -> Bar -> Maybe NotSoBroken
makeNotSoBroken foo bar = Nothing

(перший рядок - це декларація про підпис типу: makeNotSoBrokenбере аргументи a Fooі a Barі виробляє a Maybe NotSoBroken).

Тип повернення повинен бути, Maybe NotSoBrokenа не просто NotSoBrokenтому, що ми сказали йому оцінювати Nothing, що є конструктором значень Maybe. Типи просто не вирівняються, якби ми писали щось інше.

Крім абсолютно безглуздої, ця функція навіть не відповідає своєму реальному призначенню, як ми побачимо, коли ми намагаємось її використовувати. Створимо функцію, useNotSoBrokenяка називається NotSoBrokenаргументом:

useNotSoBroken :: NotSoBroken -> Whatever

( useNotSoBrokenприймає NotSoBrokenаргумент як аргумент і виробляє a Whatever).

І використовуйте його так:

useNotSoBroken (makeNotSoBroken)

У більшості мови така поведінка може спричинити нульовий виняток вказівника. У Haskell типи не збігаються: makeNotSoBrokenповертає a Maybe NotSoBroken, але useNotSoBrokenочікує a NotSoBroken. Ці типи не взаємозамінні, і код не може компілюватися.

Щоб обійти це, ми можемо використовувати caseоператор для розгалуження на основі структури Maybeзначення (використовуючи функцію, яку називають узгодженням шаблону ):

case makeNotSoBroken of
    Nothing  -> --handle situation here
    (Just x) -> useNotSoBroken x

Очевидно, цей фрагмент потрібно розмістити всередині якогось контексту, щоб насправді компілювати, але він демонструє основи того, як Haskell обробляє нуль. Ось покрокове пояснення вищевказаного коду:

  • По-перше, makeNotSoBrokenоцінюється, що гарантовано дає значення типу Maybe NotSoBroken.
  • caseЗаява перевіряє структуру цього значення.
  • Якщо значення є Nothing, оцінюється код "обробляти ситуацію тут".
  • Якщо замість цього значення відповідає Justзначення, виконується інша гілка. Зверніть увагу, як відповідне застереження одночасно ідентифікує значення як Justконструкцію та прив'язує своє внутрішнє NotSoBrokenполе до імені (у цьому випадку x). xможе використовуватися як нормальне NotSoBrokenзначення, яке є.

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

Я сподіваюся, що це було зрозуміле пояснення. Якщо це не має сенсу, стрибайте в Learn You A Haskell для великого добра! , один з найкращих мовних навчальних посібників в Інтернеті, який я коли-небудь читав. Сподіваємось, ви побачите ту саму красу на цій мові, яку я і роблю.


TL; DR має бути на вершині :)
andrew.fox

@ andrew.fox Добре. Я відредагую.
Наближення

0

Я думаю, що ваша цитата - солом'яний аргумент.

Сучасні мови сьогодні (включаючи C #) гарантують вам, що конструктор або повністю завершений, або його немає.

Якщо в конструкторі є виняток, і об'єкт залишається частково неініціалізованим, стан nullабо Maybe::noneнеініціалізований стан не робить реальної різниці в коді деструктора.

Вам доведеться просто впоратися з цим у будь-якому випадку. Коли є зовнішні ресурси для управління, ви повинні керувати ними явно будь-яким способом. Мови та бібліотеки можуть допомогти, але вам доведеться подумати над цим.

Btw: У C # nullзначення майже еквівалентне Maybe::none. Ви можете призначити nullлише змінним та членам об'єкта, які на рівні типу оголошені нульовими :

String? nullableString = getOptionalString();
Nullable<String> maybe = nullableString; // This is equivalent

Це нічим не відрізняється від наступного фрагмента:

Maybe<String> optionalString = getOptionalString();

Отже, підсумовуючи, я не бачу, наскільки зведеність нічим не протилежна Maybeтипам. Я б навіть припустив, що C # прокрався у своєму власному Maybeтипі і назвав його Nullable<T>.

За допомогою методів розширення навіть легко отримати очищення Nullable, щоб дотримуватись монадичної схеми:

Resource? resource = initializationThatMayFail();
...
resource.ifExists( Resource r -> r.cleanup() );

2
що це означає: "конструктор або повністю завершує, або не робить"? Наприклад, у Java, ініціалізація (не остаточного) поля в конструкторі не захищена від перегонів даних - це кваліфікується як повністю заповнене чи ні?
гнат

@gnat: що ви маєте на увазі під "Java, наприклад, ініціалізація (не остаточного) поля в конструкторі не захищена від перегонів даних". Якщо ви не зробите щось вражаюче складне з декількома потоками, шанси на перегони всередині конструктора є (або повинні бути) майже неможливими. Ви не можете отримати доступ до поля незабудованого об'єкта, за винятком конструктора об'єктів. А якщо будівництво провалиться, у вас немає посилання на об’єкт.
Роланд Тепп

Велика різниця між nullнеявним членом кожного типу і Maybe<T>тим, що буде з Maybe<T>, ви також можете мати справедливий T, який не має значення за замовчуванням.
svick

Створюючи масиви, часто неможливо буде визначити корисні значення для всіх елементів без необхідності їх зчитування, а також неможливо буде статично перевірити, що жоден елемент не читається без того, щоб для нього було обчислено корисне значення. Найкраще зробити це - ініціалізувати елементи масиву таким чином, щоб їх можна було визнати непридатними.
supercat

@svick: В C # (що стосується мови, про яку йде мова в ОП), nullце не неявний член кожного типу. Щоб nullбути лебальним значенням, вам потрібно визначити тип, який явно буде нульовим, що робить T?(синтаксичний цукор Nullable<T>) по суті еквівалентним Maybe<T>.
Roland Tepp

-3

C ++ робить це, маючи доступ до ініціалізатора, який виникає перед тілом конструктора. C # запускає ініціалізатор за замовчуванням перед тілом конструктора, він приблизно призначає 0 усьому, floatsстає 0,0, boolsстає неправдивим, посилання стають недійсними і т. Д. У C ++ ви можете змусити його запускати інший ініціалізатор, щоб гарантувати, що ненульовий тип посилання ніколи не є нульовим .

class Foo { Foo(int i) { throw new Exception("Never finishes"); }
class Bar { Bar(string s) { } }

class Broken
{
    val foo: Foo  // where Foo and Bar are non-nullable reference types
    val bar: Bar

    Broken() :
        foo = new Foo(123),// roughly causes a "goto destroy_foo;"
        bar = new Bar("never executes") { }

    // This destructory-function never runs because the constructor never completed
    ~Broken() 
    // This is made-up syntax:
    // : 
    // destroy_bar:
    // bar.~Bar();
    // destroy_foo:
    // foo.~Foo();
    {
    }
}

2
питання стосувалося мов із типами "Можливо"
gnat

3
" Посилання стають недійсними " - вся передумова питання полягає в тому, що у нас його немає null, і єдиний спосіб вказати відсутність значення - це використовувати Maybeтип (також відомий як Option), якого AFAIK C ++ не має в стандартна бібліотека. Відсутність нулів дозволяє гарантувати, що поле завжди буде дійсним як властивість системи типів . Це більш гарантія, ніж вручну, переконавшись, що немає кодового шляху там, де може бути змінна null.
амон

У той час як c ++ не має типів "Можливо" явно, такі речі, як std :: shared_ptr <T>, є досить близькими, я вважаю, що все ще актуально, що c ++ обробляє випадок, коли ініціалізація змінних може відбуватися "поза межами" конструктора, і насправді потрібно для посилальних типів (&), оскільки вони не можуть бути нульовими.
FryGuy
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.