インターフェイス(OOP)のセマンティックコントラクトは、関数シグネチャ(FP)よりも有益ですか?


16

いくつかのことで言われて、あなたはそれらの極端にSOLID原則を取る場合、あなたは関数型プログラミングで終わります。この記事に同意しますが、インターフェイス/オブジェクトから関数/クロージャへの移行でいくつかのセマンティクスが失われると思うので、関数型プログラミングがどのように損失を軽減できるかを知りたいと思います。

記事から:

さらに、インターフェイス分離の原則(ISP)を厳密に適用すると、ヘッダーインターフェイスよりもロールインターフェイスを優先する必要があることを理解できます。

デザインをどんどん小さなインターフェースに向けていくと、最終的には究極のロールインターフェース、つまり単一のメソッドを持つインターフェースに到達します。これは私によく起こります。以下に例を示します。

public interface IMessageQuery
{
    string Read(int id);
}

に依存する場合IMessageQuery暗黙の契約の一部は、呼び出しRead(id)が特定のIDのメッセージを検索して返すことです。

これを、同等の機能シグネチャである依存関係と比較してください int -> string。追加の手がかりがなければ、この関数は単純なものになりToString()ます。を実装IMessageQuery.Read(int id)したToString()場合、意図的に破壊されたと非難する可能性があります!

それでは、機能的なプログラマは、名前の良いインターフェイスのセマンティクスを保持するために何ができるでしょうか?たとえば、単一のメンバーでレコードタイプを作成することは一般的ですか?

type MessageQuery = {
    Read: int -> string
}

5
OOPインターフェースは、単一の関数ではなくFP typeclassに似ています。
9000

2
ISPを「厳密に」適用して、インターフェイスごとに1つのメソッドを作成することは、行き過ぎです。
-gbjbaanb

3
実際、@ gbjbaanbのほとんどのインターフェイスには、多くの実装を持つメソッドが1つしかありません。SOLIDの原則を適用すればするほど、そのメリットを確認できます。しかし、それはこの質問のトピック外です
-AlexFoxGill

1
@jk .:さて、Haskellでは、型クラスです。OCamlではモジュールまたはファンクターを使用し、Clojureではプロトコルを使用します。いずれの場合でも、通常、インターフェイスを1つの機能に限定することはありません。
9000

2
Without any additional clues...多分、ドキュメントが契約の一部である理由でしょうか?
SJuan76

回答:


8

Telastynが言うように、関数の静的な定義を比較します:

public string Read(int id) { /*...*/ }

let read (id:int) = //...

OOPからFPに移行するものは本当に失われていません。

ただし、これはストーリーの一部にすぎません。関数とインターフェイスは静的な定義で参照されるだけではないためです。彼らも回り込んだ。そこでMessageQuery、別のコードaによって読み取られたとしましょうMessageProcessor。それから私達にあります:

public void ProcessMessage(int messageId, IMessageQuery messageReader) { /*...*/ }

これで、メソッド名またはそのパラメーターを直接見ることはできませんが、IDEを使用して簡単に取得できます。より一般的には、intからstringへの関数のメソッドを持つインターフェイスではなく、この関数に関連付けられたパラメーター名のメタデータを保持していることを意味します。IMessageQuery.Readint idIMessageQueryid

一方、機能バージョンには次のものがあります。

let read (id:int) (messageReader : int -> string) = // ...

それで、私たちは何を保持し、失いましたか?さて、まだパラメータ名messageReaderがあり、おそらく型名(に相当するIMessageQuery)は不要になります。しかしid、関数内のパラメーター名は失われました。


これには主に2つの方法があります。

  • まず、その署名を読んで、何が起こっているのかをかなりよく推測できます。関数を短く、シンプルでまとまりを保ち、適切な命名を使用することにより、この情報を直感的に見つけたり見つけたりすることがはるかに簡単になります。実際の関数自体を読み取ると、さらに簡単になります。

  • 第二に、プリミティブをラップする小さな型を作成することは、多くの関数型言語で慣用的な設計と考えられています。この場合、反対のことが起こります-型名をパラメーター名(IMessageQueryto messageReader)で置き換える代わりに、パラメーター名を型名で置き換えることができます。たとえばint、次の型にラップできますId

    type Id = Id of int
    

    これで、read署名は次のようになります。

    let read (id:int) (messageReader : Id -> string) = // ...
    

    これは、以前と同じように有益です。

    サイドノートとして、これはOOPで持っていたコンパイラ保護の一部も提供します。OOPのバージョンは、我々は特に取っ確保のに対しIMessageQuery、むしろただの古いよりもint -> string機能を、ここで我々は取っていることに類似した(しかし異なる)保護持つId -> stringだけではなく、任意の古いですint -> string


これらの手法は常にインターフェイス上で完全な情報を利用できるのと同じくらい優れており、有益であると100%自信を持って言いたくありませんが、上記の例から、ほとんどの場合、私たちは言うことができると思いますおそらく同じくらい良い仕事をすることができます。


1
これは私のお気に入りの答えです
-FP

10

FPを実行する場合、より具体的なセマンティックタイプを使用する傾向があります。

たとえば、私の方法は次のようになります。

read: MessageId -> Message

これは、OO(/ java)スタイルのThingDoer.doThing()スタイルよりも多くのことを伝えます


2
+1。さらに、Haskellなどの型付きFP言語では、はるかに多くのプロパティを型システムにエンコードできます。それがhaskellタイプの場合、IOがまったく実行されていないことがわかります(したがって、おそらくどこかでメッセージへのidのメモリ内マッピングを参照します)。OOP言語では、私はその情報を持っていません-この関数は呼び出しの一部としてデータベースを呼び出します。
ジャック

1
これがJavaスタイルより多くの情報を伝達する理由については、はっきりとはわかりません。何をしないread: MessageId -> Messageことを教えてくれstring MessageReader.GetMessage(int messageId)ませんか?
ベンアーロンソン

@BenAaronsonは、信号対雑音比が優れています。それがOOインスタンスメソッドである場合、私はそれが内部で何をしているのかわからない、それは表現されていない任意の種類の依存関係を持つことができます。ジャックが言及しているように、あなたがより強いタイプを持っているとき、あなたはより多くの保証を持っています。
デニス

9

それでは、機能的なプログラマは、名前の良いインターフェイスのセマンティクスを保持するために何ができるでしょうか?

適切な名前の関数を使用します。

IMessageQuery::Read: int -> string単純にReadMessageQuery: int -> string似たようなものになります。

注意すべき重要なことは、名前は単語の最も緩やかな意味での契約にすぎないということです。あなたと他のプログラマーが名前から同じ意味を推測し、それに従う場合にのみ機能します。このため、その暗黙の動作を伝える任意の名前を実際に使用できます。オブジェクト指向プログラミングと関数型プログラミングでは、名前がわずかに異なる場所にあり、形状が若干異なりますが、機能は同じです。

インターフェイス(OOP)のセマンティックコントラクトは、関数シグネチャ(FP)よりも有益ですか?

この例ではありません。上で説明したように、単一の関数を持つ単一のクラス/インターフェースは、同様の名前が付けられたスタンドアロン関数よりも意味のある情報量が多くありません。

クラスで複数の関数/フィールド/プロパティを取得すると、それらの関係を確認できるため、それらに関する詳細情報を推測できます。同じ/類似のパラメータを取るスタンドアロン関数や、名前空間やモジュールごとに編成されたスタンドアロン関数よりも有益な場合は、議論の余地があります。

個人的には、より複雑な例であっても、オブジェクト指向がはるかに有益であるとは思いません。


2
パラメーター名はどうですか?
ベンアーロンソン

@BenAaronson-それらはどうですか?オブジェクト指向言語と関数型言語の両方でパラメーター名を指定できるため、プッシュです。質問と一致させるために、ここではタイプシグネチャの短縮形を使用しています。実際の言語では、次のようになりますReadMessageQuery id = <code to go fetch based on id>
Telastyn

1
関数がパラメーターとしてインターフェイスを受け取り、そのインターフェイスでメソッドを呼び出す場合、インターフェイスメソッドのパラメーター名はすぐに使用できます。関数が別の関数をパラメーターとしてとる場合、関数で渡されるパラメーター名を割り当てるのは簡単ではありません
ベンアーロンソン

1
はい、@ BenAaronsonはこれが関数シグネチャで失われる情報の1つです。たとえば、let consumer (messageQuery : int -> string) = messageQuery 5では、idパラメータは単なるintです。私は1つの引数は、Idではなく、渡す必要があることだと思いますint。実際、それはそれ自体でまともな答えを出すでしょう。
AlexFoxGill

@AlexFoxGill実際、私はまさにそれらのラインに沿って何かを書く過程にありました!
ベンアーロンソン

0

単一の関数が「セマンティックコントラクト」を持つことはできないことに同意しません。次の法律を検討してくださいfoldr

foldr f z nil = z
foldr f z (singleton x) = f x z
foldr f z (xn <> ys) = foldr f (foldr f z ys) xn

それは意味ではなく、契約ではありませんか?特にfoldrこれらの法律によって一意に決定されるため、「フォルダ」のタイプを定義する必要はありません。あなたはそれが何をするかを正確に知っています。

何らかのタイプの関数を使用する場合は、同じことを実行できます。

-- The first argument `f` must satisfy for all x, y, z
-- > f x x = true
-- > f x y = true and f y x = true implies x = y
-- > f x y = true and f y z = true implies f x z = true
sort :: forall 'a. (a -> a -> bool.t) -> list.t a -> list.t a;

同じ契約を複数回必要とする場合にのみ、そのタイプに名前を付けてキャプチャする必要があります。

-- Type of functions `f` satisfying, for all x, y, z
-- > f x x = true
-- > f x y = true and f y x = true implies x = y
-- > f x y = true and f y z = true implies f x z = true
type comparison.t 'a = a -> a -> bool.t;

型チェッカーは、型に割り当てるセマンティクスを強制しません。そのため、すべてのコントラクトに対して新しい型を作成することは定型的なことです。


1
私はあなたの最初の点が問題に対処するとは思わない-それは関数の定義であり、署名ではない。もちろん、クラスまたは関数の実装を見れば、それが何をするのかを知ることができます-質問は抽象レベル(インターフェイスまたは関数のシグネチャ)でセマンティクスを保持できるかどうかを尋ねます
-AlexFoxGill

@AlexFoxGill -それはだではない、それは一意に決めるんが、関数定義foldr。ヒント:の定義にfoldrは2つの方程式があり(理由)、上記の仕様には3つの方程式があります。
ジョナサンキャスト

つまり、foldrは関数シグネチャではなく関数です。法律または定義は署名の一部ではありません
-AlexFoxGill

-1

ほとんどすべての静的に型付けされた関数型言語には、セマンティックインテントを明示的に宣言する必要がある方法で基本型をエイリアスする方法があります。他の回答のいくつかは例を示しています。実際には、経験豊富な関数型プログラマーは、構成可能性と再利用性を損なうため、これらのラッパータイプを使用する非常に良い理由を必要とします。

たとえば、クライアントがリストに裏付けされたメッセージクエリの実装を望んでいるとします。Haskellでは、実装は次のように簡単です。

messages = ["Message 0", "Message 1", "Message 2"]
messageQuery = (messages !!)

newtype Message = Message Stringこれを使用するのはそれほど簡単ではなく、この実装は次のようになります。

messages = map Message ["Message 0", "Message 1", "Message 2"]
messageQuery (Id index) = messages !! index

それは大したことではないように思えるかもしれませんが、どこでもその型変換をやり取りするか、コード内に境界層を設定して、上記のすべてInt -> Stringを変換してId -> Messageから下の層に渡す必要があります。国際化を追加したり、すべて大文字でフォーマットしたり、ロギングコンテキストを追加したりしたい、などと言います。それらの操作はすべて、Int -> Stringで構成するのはとても簡単で、で迷惑Id -> Messageです。型の制限を大きくすることが望ましい場合があるということではありませんが、煩わしさはトレードオフに見合うだけの価値があります。

ラッパーの代わりに型シノニムを使用できます(Haskell typeではなくnewtype)。これはより一般的であり、至る所で変換を必要としませんが、OOPバージョンのような静的な型保証を提供しません。少しだけひっかかりました。タイプラッパーは、ほとんどの場合、クライアントが値をまったく操作せず、値を保存して戻すことを想定していない場合に使用されます。たとえば、ファイルハンドル。

クライアントが「破壊的」であることを妨げるものは何もありません。すべてのクライアントに対して、ジャンプするフープを作成しているだけです。一般的な単体テストの単純なモックには、多くの場合、実稼働では意味をなさない奇妙な動作が必要です。インターフェースは、可能な限り気にしないように作成する必要があります。


1
これは、Ben / Daenythの答えに対するコメントのように感じられます-セマンティックタイプを使用できますが、不便さのためではないということですか?私はあなたに反対票を投じませんでしたが、それはこの質問に対する答えではありません。
AlexFoxGill

-1構成性や再利用性をまったく害しません。場合IdMessageするための単純なラッパーですIntString些細なそれらの間で変換します。
マイケルショー

はい、それはささいなことですが、それでもあちこちでやるのは面倒です。型の同義語とラッパーの使用法の違いを説明する例を追加しました。
カールビーレフェルト

これはサイズのようです。小規模なプロジェクトでは、これらの種類のラッパー型はおそらくとにかく有用ではありません。大規模なプロジェクトでは、境界がコード全体に占める割合が小さいのが自然であるため、これらのタイプの変換を境界で行い、ラップされたタイプを他の場所に渡すことはそれほど難しくありません。また、アレックスが言ったように、これがどのように質問に対する答えであるかわかりません。
ベンアーロンソン

私はそれを行う方法を説明し、それから機能プログラマがそれをしない理由を説明しました。たとえそれが人々が望んでいたものでなくても、それは答えです。サイズとは関係ありません。コードを再利用することを意図的に難しくするのはどういうわけか良いことだというこの考え方は、OOPの概念です。何らかの価値を追加する製品タイプを作成しますか?ずっと。1つのライブラリでしか使用できないことを除いて、広く使用されているタイプとまったく同じタイプを作成しますか?おそらく、OOPコードと頻繁にやり取りするFPコード、またはOOPイディオムに精通している誰かによって書かれたFPコードを除いて、実際には前代未聞です。
カールビーレフェルト
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.