nullではなくMaybe型の言語はエッジ条件をどのように処理しますか?


53

Eric Lippertは、C#が型nullではなくaを使用する理由についての彼の議論でMaybe<T>非常に興味深い点を述べまし

型システムの一貫性が重要です。null不可の参照が無効であると判断された状況下では決してないことを常に知ることができますか?参照型のnull不可フィールドを持つオブジェクトのコンストラクターではどうでしょうか?そのようなオブジェクトのファイナライザでは、参照を埋めるはずのコードが例外をスローしたためにオブジェクトがファイナライズされますが、どうでしょうか?その保証についてあなたに嘘をついている型システムは危険です。

それは少し驚くべきものでした。関連する概念に興味があり、コンパイラや型システムをいじくり回しましたが、そのシナリオについては考えませんでした。nullの代わりにMaybe型を持つ言語は、初期化やエラー回復など、想定される非null参照が実際には有効な状態にないエッジケースをどのように処理しますか?


Maybeが言語の一部である場合は、nullポインターを介して内部的に実装されている可能性があり、単なる構文上の砂糖です。しかし、私はどの言語も実際にこれを行うとは思わない。
パンジー

1
@panzi:セイロンはフローセンシティブタイピングを使用してType?(たぶん)とType(nullではない)
Lukas Eder

1
@RobertHarvey Stack Exchangeには既に「いい質問」ボタンがありませんか?
user253751

2
@panziこれは素晴らしく有効な最適化ですが、この問題を解決することはできません。何かがでない場合Maybe T、そうではないNoneため、ストレージをNULLポインターに初期化できません。

@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()
    }
}

このシナリオでは、インスタンスの構築中にエラーがスローされるため、インスタンスが完全に構築される前に構築が中止されます。この言語は、メモリの割り当てが解除される前に実行されるデストラクタメソッドを提供します。たとえば、非メモリリソースを手動で解放します。手動で管理されるリソースは、構築が中止される前にすでに割り当てられている可能性があるため、部分的に構築されたオブジェクトでも実行する必要があります。

nullを使用すると、デストラクタは変数がのように割り当てられたかどうかをテストできますif (foo != null) foo.cleanup()。nullがなければ、オブジェクトは未定義の状態になりますbar。値は何ですか?

ただし、この問題は3つの側面の組み合わせが原因で発生します。

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

したがって、nullが存在しなくても問題はありませんが、nullが存在しない他の一連の機能の組み合わせでは問題はありません。

興味深い質問は、C#が1つの設計を選択し、他の設計を選択しなかった理由です。ここでは、引用のコンテキストに、C#言語のnullに対する他の多くの引数がリストされています。これらは、主に「親しみやすさと互換性」として要約できます。


ファイナライザがnulls を処理する必要がある別の理由もあります。参照サイクルの可能性があるため、ファイナライズの順序は保証されません。しかし、私はあなたのFINALIZEデザインもそれを解決しfooていると思います:すでにファイナライズされている場合、そのFINALIZEセクションは単に実行されません。
svick

14

他のデータが有効な状態であることを保証するのと同じ方法。

値を完全に作成しないと、あるタイプの変数/フィールドを持たないように、セマンティクスと制御フローを構築できます。オブジェクトを作成してコンストラクターにそのフィールドに「初期」値を割り当てさせる代わりに、すべてのフィールドの値を一度に指定することによってのみオブジェクトを作成できます。変数を宣言してから初期値を割り当てる代わりに、初期化のある変数のみを導入できます。

たとえば、Rust Point { x: 1, y: 2 }では、を行うコンストラクタを記述する代わりに、を介してstruct型のオブジェクトを作成しますself.x = 1; self.y = 2;。もちろん、これはあなたが考えている言語のスタイルと衝突するかもしれません。

別の補完的なアプローチは、初期化前にストレージへのアクセスを防ぐために活性分析を使用することです。これにより、最初の読み取りの前に確実に割り当てられている限り、変数をすぐに初期化せずに宣言できます。また、次のような障害関連のケースもキャッチできます。

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

技術的には、オブジェクトの任意のデフォルト初期化を定義することもできます。たとえば、すべての数値フィールドをゼロにしたり、配列フィールドに空の配列を作成したりします。


7

Haskellの仕組みは次のとおりです(Haskellはオブジェクト指向言語ではないため、Lippertの発言に対する正確なカウンターではありません)。

警告:深刻なHaskellのファンボーイからの長い回答。

TL; DR

この例は、HaskellとC#の違いを正確に示しています。構造構築のロジスティクスをコンストラクターに委任する代わりに、周囲のコードで処理する必要があります。Nothingnull値はMaybe通常の非non-valueと互換性がない/直接変換できないと呼ばれる特別なラッパータイプ内でのみ発生するため、null(またはHaskellの)値がnull以外の値を予期する場所に切り取られる方法はありません。 nullable型。a Maybeでラップしてnull可能にした値を使用するには、まずパターンマッチングを使用して値を抽出する必要があります。これにより、null以外の値があることが確実にわかっているブランチに制御フローを迂回させることができます。

したがって:

null不可の参照が無効であると判断された状況下では決してないことを常に知ることができますか?

はい。 IntそしてMaybe Int、2つの完全に独立したタイプです。検索Nothing平野では、Int内の文字列「魚」を見つけるに匹敵するだろうInt32

参照型のnull不可フィールドを持つオブジェクトのコンストラクターではどうでしょうか?

問題ではありません:Haskellの値コンストラクターは、与えられた値を取り、それらをまとめる以外に何もできません。すべての初期化ロジックは、コンストラクターが呼び出される前に実行されます。

そのようなオブジェクトのファイナライザでは、参照を埋めるはずのコードが例外をスローしたためにオブジェクトがファイナライズされますが、どうでしょうか?

Haskellにはファイナライザがないため、これに実際に対処することはできません。ただし、私の最初の応答はまだ有効です。

完全な回答

Haskellにはnullがなく、Maybeデータ型を使用してnullableを表します。たぶん、このように定義された藻類のデータ型です:

data Maybe a = Just a | Nothing

Haskellに不慣れな方は、これを「A Maybeis a Nothingor a Just a」と読んでください。具体的には:

  • Maybeあるタイプのコンストラクタ:それは(ジェネリッククラスとして(間違って)考えることができa型変数です)。C#の例えはclass Maybe<a>{}です。
  • Just値コンストラクターです。これは、型の引数を1つ受け取り、その値を含むa型の値を返す関数Maybe aです。したがって、コードx = Just 17はに類似していint? x = 17;ます。
  • Nothingは別の値コンストラクタですが、引数を取らず、Maybe返される値は「Nothing」以外の値を持ちません。x = Nothingは、Haskellのint? x = null;制約をにするaと仮定しますInt。これはを書くことで実行できますx = Nothing :: Maybe Int)。

Maybeタイプの基本が邪魔にならないので、HaskellはOPの質問で議論された問題をどのように回避しますか?

Haskellは、これまでに説明したほとんどの言語とはまったく異なるため、最初にいくつかの基本的な言語の原則を説明します。

まず、Haskellでは、すべてが不変です。すべて。名前は、値を保存できるメモリの場所ではなく、値を参照します(これだけでも、バグを除去するための非常に大きなソースです)。変数宣言と割り当てはHaskellの値に2つの別々の操作は、その値(例えばを定義することによって作成されるC#、とは異なりx = 15y = "quux"z = Nothing)、変更しないことができます。したがって、次のようなコード:

ReferenceType x;

Haskellでは不可能です。値を初期化しても問題はありません。nullすべての値が存在するには、値を明示的に初期化する必要があるためです。

第二に、Haskellはオブジェクト指向言語ではない:それは純粋に機能的な言語なので、単語の厳密な意味でないオブジェクトが存在しません。代わりに、引数を取り、統合された構造を返す単純な関数(値コンストラクター)があります。

次に、絶対的なスタイルコードはまったくありません。これにより、ほとんどの言語は次のようなパターンに従います。

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

プログラムの動作は一連の指示として表されます。オブジェクト指向言語では、クラスと関数の宣言もプログラムの流れに大きな役割を果たしますが、本質的には、プログラムの実行の「肉」は実行される一連の命令の形を取ります。

Haskellでは、これは不可能です。代わりに、プログラムフローは関数の連鎖によって完全に決定されます。命令型の表記でも、do匿名関数を>>=演算子に渡すための構文上の砂糖です。すべての関数の形式は次のとおりです。

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

body-expression値に評価されるものはどこでもかまいません。明らかにより多くの構文機能が利用可能ですが、主なポイントはステートメントのシーケンスが完全にないことです。

最後に、そしておそらく最も重要なこととして、Haskellの型システムは非常に厳密です。 Haskellの型システムの中心的な設計哲学を要約しなければならないとしたら、「コンパイル時にできる限り多くのことを間違って実行し、実行時にできるだけ問題を起こさない」と言います。一切の暗黙的な変換はありません(促進したいIntのはDouble?使用fromIntegral機能)。実行時に無効な値が発生する可能性があるのは、使用することだけですPrelude.undefined(明らかに存在する必要があり、削除することは不可能です)。

これらすべてを念頭に置いて、amonの「壊れた」例を見て、このコードをHaskellで再表現してみましょう。まず、データ宣言(名前付きフィールドのレコード構文を使用):

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

fooそしてbar実際に機能アクセサ匿名フィールドここではなく、実際のフィールドにしているが、我々は、この詳細を無視することができます)。

NotSoBroken値コンストラクタは、服用以外の任意のアクション取ることができないFooBar(NULL可能ではありません)となってNotSoBroken、それらのうちに。命令コードを入力したり、フィールドを手動で割り当てたりする場所さえありません。すべての初期化ロジックは、他の場所、ほとんどの場合専用のファクトリー関数で実行する必要があります。

例では、Broken常に構築に失敗します。NotSoBroken同様の方法で値コンストラクターを破壊する方法はありません(コードを記述する場所はありません)が、同様に欠陥のあるファクトリー関数を作成できます。

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

(最初の行は型シグネチャ宣言です:引数としてmakeNotSoBrokena Fooとa Barを取り、を生成しますMaybe NotSoBroken)。

戻り値の型があることが必要Maybe NotSoBrokenではなく、単にNotSoBrokenので、我々はに評価するためにそれを告げたNothingため値コンストラクタです、Maybe。別の何かを書いた場合、型は単純に整列しません。

絶対に意味がないことは別として、この関数は実際の目的さえ果たせません。使用しようとするときに見ます。を引数としてuseNotSoBroken期待するという関数を作成しましょうNotSoBroken

useNotSoBroken :: NotSoBroken -> Whatever

(引数としてuseNotSoBrokena NotSoBrokenを受け入れ、を生成しますWhatever)。

そして次のように使用します:

useNotSoBroken (makeNotSoBroken)

ほとんどの言語では、この種の動作によりNULLポインター例外が発生する場合があります。Haskellでは、型は一致しません:makeNotSoBrokenaを返しますが、a Maybe NotSoBrokenuseNotSoBroken期待しNotSoBrokenます。これらの型は互換性がなく、コードはコンパイルに失敗します。

これを回避するためcaseに、Maybe値の構造に基づいて分岐するステートメントを使用できます(パターンマッチングと呼ばれる機能を使用)。

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

明らかにこのスニペットは、実際にコンパイルするために何らかのコンテキスト内に配置する必要がありますが、Haskellがnullableを処理する方法の基本を示しています。上記のコードの段階的な説明は次のとおりです。

  • 最初にmakeNotSoBrokenが評価され、typeの値を生成することが保証されMaybe NotSoBrokenます。
  • このcaseステートメントは、この値の構造を検査します。
  • 値がのNothing場合、「ここで状況を処理する」コードが評価されます。
  • 値が代わりに値と一致するJust場合、他のブランチが実行されます。一致する句が、値をJust構成として同時に識別し、その内部NotSoBrokenフィールドを名前(この場合はx)にバインドする方法に注意してください。つまりx、通常のNotSoBroken値のように使用できます。

したがって、パターンマッチングは、オブジェクトの構造が制御の分岐と不可分に結びついているため型の安全性を強化するための強力な機能を提供します

これがわかりやすい説明であったことを願っています。意味がわからない場合は、Learn You A Haskell For Great Good!、今まで読んだ中で最高のオンライン言語チュートリアルの1つ。この言語で私と同じ美しさが見られることを願っています。


TL; DRが一番上にあるはずです:)
andrew.fox

@ andrew.fox良い点。編集します。
ApproachingDarknessFish

0

あなたの引用はストローマンの議論だと思います。

現代の言語(C#を含む)は、コンストラクターが完全に完了するか、完了しないことを保証します。

コンストラクターに例外があり、オブジェクトが部分的に初期化されていないnull場合Maybe::none、初期化されていない状態でも、デストラクターコードに実際の違いはありません。

どちらの方法でも対処する必要があります。管理する外部リソースがある場合、それらを明示的に管理する必要があります。言語とライブラリが役立ちますが、これについてはいくつか考えなければなりません。

Btw:C#では、null値はとほぼ同等Maybe::noneです。null型レベルでnullableと宣言されている変数とオブジェクトメンバーにのみ割り当てることができます。

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

これは、次のスニペットと何の違いもありません。

Maybe<String> optionalString = getOptionalString();

したがって、結論として、nullabilityが型とどのように反対するかはわかりませんMaybe。私は、C#がそれ自身のMaybe型に潜入し、それを呼び出すことさえ提案しNullable<T>ます。

拡張メソッドを使用すると、Nullableのクリーンアップを取得して、モナドパターンに従うことさえ簡単です。

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

2
「コンストラクターは完全に完了するか、完了しないかのどちらか」たとえば、Javaでは、コンストラクターの(最終ではない)フィールドの初期化はデータの競合から保護されていません-それは完全に完了したとみなされますか?
ブヨ

@gnat:「Javaの場合、たとえば、コンストラクターの(最終ではない)フィールドの初期化はデータの競合から保護されていません」とはどういう意味ですか。複数のスレッドを含む見事に複雑なことをしない限り、コンストラクター内で競合状態が発生する可能性はほとんどありません(またはそうあるべきです)。オブジェクトコンストラクター内を除き、未構築オブジェクトのフィールドにアクセスすることはできません。構築が失敗した場合、オブジェクトへの参照がありません。
ローランドテップ

nullすべての型の暗黙的なメンバーとの間の大きな違いMaybe<T>は、with を使用して、デフォルト値を持たないMaybe<T>justを使用することもできTます。
svick

配列を作成するとき、いくつかを読み取ることなくすべての要素の有用な値を決定することはしばしば不可能であり、有用な値が計算されていない要素が読み取られないことを静的に検証することもできません。最適な方法は、配列要素を使用不可として認識できるように初期化することです。
supercat 14

@svick:C#(OPが問題にしている言語)ではnull、すべてのタイプの暗黙的なメンバーではありません。nulllebal値になるように、あなたが作る明示的にNULL可能にするタイプ、定義する必要がありますT?(シンタックスシュガーのためNullable<T>)と本質的に同等をMaybe<T>
ローランドテップ14

-3

C ++は、コンストラクター本体の前に発生する初期化子にアクセスすることにより、これを行います。C#は、コンストラクター本体の前にデフォルトのイニシャライザーを実行します。すべてに0を割り当て、floats0.0にboolsなり、falseになり、参照がnullになるなど。 。

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
質問は多分種類の言語についてだった
ブヨ

3
参照はnullになります」-質問の前提は、持っていないことnullであり、値の不在を示す唯一の方法は、Maybeタイプ(別名Option)を使用することです。標準ライブラリ。nullが存在しないため、フィールドが型システムのプロパティとして常に有効であることを保証できます。これは、変数が存在する可能性がある場所にコードパスが存在しないことを手動で確認するよりも強力な保証です。null
アモン

c ++にはネイティブにMaybe型が明示的に含まれていませんが、std :: shared_ptr <T>のようなものは十分に近いため、c ++がコンストラクタの「スコープ外」で変数の初期化が発生するケースを処理することはまだ関連があると思います参照型(&)はnullにできないため、実際には参照型に必要です。
FryGuy
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.