(jp) =
私はしばらくの間、プログラミング言語の型システムに魅了されてきました。 最近、継承と型について何かが気になりました。
型の分散が明確になっただけでなく、リスコフの置換原理が実際に何を意味するのかについても理解できました。 今日は、これらの洞察を皆さんと共有します。
# 前提条件
私が話していることを明確にするために、疑似コードを書きます。 それでは、この疑似コードの構文がどのようなものになるかを確認しておきましょう。
関数はこのように定義されます。
foo(T) : void
bar(S) : T
最初に関数名、2 番目に型をパラメーターとして持つ引数リスト、最後に戻り値の型が続きます。 関数が何も返さない場合は、次のように示されます。 void
.
関数は、型と同様に、別の関数を拡張 (上書き) できます。 継承はそのように定義されています。
bar > baz(S) : T
T > S
この例では、 baz
伸びる bar
、 と S
のサブタイプです T
. 最後のステップは、関数を呼び出すことができるようにすることです。これはそのように行われます。
foo(T)
a = bar(S)
繰り返しになりますが、これはすべて疑似コードであり、これを使用して、型とは何か、継承と組み合わせて定義できる方法とできない方法、およびこれがどのようにタイプ セーフなシステムになるかを示します。
# リスコフ置換原理
LSP の正式な定義を見てみましょう。
もしも
S
のサブタイプですT
、次にタイプのオブジェクトT
タイプのオブジェクトに置き換えることができますS
—ウィキペディア
使用する代わりに S
と T
、例ではより具体的な型を使用します。
Organism > Animal > Cat
これらは、私たちが扱う3つのタイプです。 Liskov は、型のオブジェクトはどこでも Organism
コードに表示される場合、それらは次のようなサブタイプで置き換え可能でなければなりません Animal
また Cat
.
に使用される関数があるとしましょう feed
を Organism
.
feed(Organism) : void
次のように呼び出すことができる必要があります。
feed(Animal)
feed(Cat)
関数定義を契約、約束と考えてみてください。 プログラマーを使用するため。 契約には次のように記載されています。
タイプのオブジェクトが与えられた場合
Organism
、私は実行できるようになりますfeed
それOrganism
.
なぜなら Animal
と Cat
のサブタイプです Organism
、LSPは、これらのサブタイプの1つが使用されている場合にも、この機能が機能するはずであると述べています。
これは、継承の重要な特性の 1 つにつながります。 Liskovが次のタイプのオブジェクトを述べている場合 Organism
タイプのオブジェクトで置き換え可能でなければなりません Animal
、 だということだ Animal
私たちの期待を変えることはできないかもしれません Organism
.
Animal
延長する可能性があります Organism
、可能性があることを意味します 追加 機能性ですが、 Animal
によって与えられる確実性を変えることはできない Organism
.
これは、多くの OO プログラマーが間違いを犯すところです。 彼らは継承を、その親によって定義された動作を拡張するというよりも、「親タイプの一部を再利用し、サブタイプの他の部分をオーバーライドする」ように見ています。 これは、LSP が防御するものです。
# LSP の利点
継承によるタイプ セーフの詳細を調べる前に、立ち止まって、この原則に従うことで何が得られるかを自問する必要があります。 Barbara Liskov が定義した意味を説明しましたが、なぜそれが必要なのですか? 壊してはダメですか?
「約束」や「契約」の考え方について触れました。 関数や型が何ができるかを約束している場合、盲目的にそれを信頼できるはずです。 関数に頼れない場合 feed
すべてを養うことができる Organisms
、私たちのコードには文書化されていない動作があります。
LSP が尊重されていることがわかっている場合は、セキュリティのレベルがあります。 この関数が期待どおりの動作をすることを信頼できます。 その機能の実装を見なくても。 ただし、契約に違反したとき。 プログラマーとコンパイラーの両方が予測できなかった (または予期していなかった) 実行時エラーが発生する可能性があります。
上記の例では、開発者の観点から LSP を尊重することを検討しました。 ただし、別の当事者が関与しています: 言語の型システムです。 言語は、タイプ セーフな方法で設計することも、しないこともできます。 型は、関数が実行したいことを実行するかどうかを数学的に証明するための構成要素です。
では、次は。 もう一方の側面、つまり言語レベルでの型安全性について見ていきます。
# 型安全
言語によってタイプ セーフがどのように保証されるか (または保証されないか) を理解するために、これらの関数を見てみましょう。
take_care(Animal) : void
take_care > feed(Animal) : void
ご覧のように、 feed
伸びる take_care
そして、その親署名に 1 対 1 に従います。 一部のプログラミング言語では、子が親の型シグネチャを変更することを許可していません。 これを型不変性と呼びます。
タイプは許可されていないため、継承でタイプ セーフを処理する最も簡単な方法です。 変化 継承するとき。
しかし、例の型が互いにどのように関連しているかを振り返ってみると、 Cat
伸びる Animal
. 以下が可能かどうか見てみましょう。
take_care(Animal) : void
take_care > feed(Cat) : void
LSP はオブジェクトに関するルールのみを定義するため、一見すると、関数定義自体がルールに違反することはありません。 問題は、この関数が呼び出されたときに LSP を適切に使用できるかどうかです。
私達はことを知っています feed
から伸びる take_care
、したがって、少なくともその親と同じコントラクトを提供します。 私たちもそれを知っています take_care
許可します Animal
および使用するサブタイプ。 そう feed
また、取ることができるはずです Animal
タイプ。
feed(Animal)
// Type error
残念ながら、そうではありません。 型エラーが発生しています。 私たちがここで何をしているのか分かりますか? LSP を関数のパラメーターだけに適用する代わりに、同じ原則を関数自体にも適用しています。
の呼び出しがどこでも
take_care
が使用されている場合、それを次の呼び出しに置き換えることができなければなりませんfeed
.
これは、関数がコード内のスタンドアロン エンティティではなく、型自体を表すクラスの一部である OO 言語では特に意味があります。
システムのタイプセーフを維持するために、子がパラメーターの型をより具体的にすることを許可しない場合があります。 これは、親から与えられた約束を破ります。
ただし、次の定義を見てください。
take_care(Animal) : void
take_care > feed(Organism) : void
この定義は型の安全性を保証しますか? 最初は後ろ向きに見えるかもしれませんが、そうです。
feed
によって指定された契約に従います take_care
. かかります Animal
引数として、問題なく動作します。
この場合、 feed
親のコントラクトを尊重しながら、許可されるパラメーターの型を広げます。 これを反変性と呼びます。 型システムが安全であるためには、引数リストの型は反変でなければなりません。
# 戻り型の差異
戻り値の型に移ります。 例を理解するために、定義する必要のある型がさらにいくつかあります。 言葉の選択については事前に申し訳ありません!
Excretion > Poop
そして、これらは私たちが取り組んでいる機能です。
take_care(Animal) : Excretion
take_care > feed(Animal) : Poop
今の質問: オーバーライドされた戻り値の型は安全ですか? 引数リストの反変性とは対照的に、この例は実際には型安全です!
親の定義 take_care
この関数は常に型のオブジェクトを返すことを示しています Excretion
.
excretion = take_care(Animal)
excretion = feed(Animal)
なぜなら Poop
のサブタイプです Excretion
、私たちは 100% 確信することができます feed
の範疇となります。 Excretion
.
関数パラメーターと比較して、戻り値の型には反対のルールが適用されることがわかります。 戻り値の型の場合、共分散または共変型と呼んでいます。
# 実生活への影響
タイプ セーフな言語が常にバグのないプログラムを作成するという保証はありません。 言語設計は、LSP を尊重する責任の半分しか負っていないことがわかりました。 残りの半分はプログラマーの仕事です。
ただし、言語は異なりますが、すべて独自の型システムがあり、それぞれ異なるレベルの型安全性があります。
たとえば、Eiffel では、パラメーターの共分散が考慮されます。 これは、コンパイラによって検出されない、誤った動作の領域が存在する可能性があることを意味します。 したがって、実行時エラーの可能性があります。
PHP では、子クラスのコンストラクターが別の署名を持つことができますが、他のすべての関数については不変の型システムを維持します。 PHP に関する多くのことと同様に、この矛盾は開発者にとって混乱を招きます。
Java、C#、Rust などの一部の言語には、今日は取り上げなかったジェネリックという概念があります。 タイプの差異もそこで大きな役割を果たします。 そのトピックは、このブログ投稿の範囲外ですが、将来取り上げる可能性があります。
これらすべての違いがありますが、覚えておくべきことが 1 つあります。 型システムの安全性は、言語の良し悪しを意味するものではありません。 非常に強力な型システムの恩恵を受けるケースもあれば、正反対のものが必要なケースもあると言っても過言ではありません。 重要なポイントは、すべてのプログラマーは、自分が最も慣れている言語の概念とパラダイムだけでなく、それ以上のことを学ぶ必要があるということです。 広い視野は、現在も将来も有益です。
では、型安全性についてどう思いますか? もしよろしければ、もっとお話したいと思います。 ツイッター または電子メール。