Czy kontrakt semantyczny interfejsu (OOP) ma więcej informacji niż podpis funkcji (FP)?

16

Niektórzy mówią, że jeśli doprowadzisz ich ekstremalne zasady do SOLID, skończysz na programowaniu funkcjonalnym . Zgadzam się z tym artykułem, ale myślę, że niektóre semantyki zostały utracone podczas przejścia z interfejsu / obiektu do funkcji / zamknięcia i chcę wiedzieć, w jaki sposób programowanie funkcjonalne może złagodzić utratę.

Z artykułu:

Ponadto, jeśli rygorystycznie zastosujesz zasadę segregacji interfejsów (ISP), zrozumiesz, że powinieneś faworyzować interfejsy ról zamiast interfejsów nagłówka.

Jeśli nadal będziesz kierować swoim projektem w kierunku coraz mniejszych interfejsów, ostatecznie dojdziesz do ostatecznego interfejsu roli: interfejsu z jedną metodą. To mi się często zdarza. Oto przykład:

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

Jeśli wezmę zależność od IMessageQuery, częścią niejawnej umowy jest to, że wywołanie Read(id)wyszuka i zwróci wiadomość o podanym identyfikatorze.

Porównajmy to do robienia zależność od jego odpowiednik podpisu funkcjonalnej int -> string. Bez dodatkowych wskazówek ta funkcja może być prosta ToString(). Jeśli wdrożysz IMessageQuery.Read(int id)za pomocą ToString()I, mogę cię oskarżyć o celową wywrotę!

Co zatem mogą zrobić funkcjonalni programiści, aby zachować semantykę dobrze nazwanego interfejsu? Czy konwencjonalne jest na przykład tworzenie typu rekordu z jednym elementem?

type MessageQuery = {
    Read: int -> string
}
AlexFoxGill
źródło
5
Interfejs OOP bardziej przypomina typ FP , a nie pojedynczą funkcję.
9000
2
Możesz mieć zbyt wiele dobrych rzeczy, stosowanie rygorystycznie ISP, aby otrzymać 1 metodę na interfejs, idzie zbyt daleko.
gbjbaanb
3
@ gbjbaanb w rzeczywistości większość moich interfejsów ma tylko jedną metodę z wieloma implementacjami. Im bardziej zastosujesz zasady SOLID, tym bardziej dostrzeżesz korzyści. Ale to nie na temat tego pytania
AlexFoxGill
1
@jk .: Cóż, w Haskell są to klasy typów, w OCaml używasz modułu lub funktora, w Clojure używasz protokołu. W każdym razie zwykle nie ograniczasz analogii interfejsu do jednej funkcji.
9000
2
Without any additional clues... może dlatego dokumentacja jest częścią umowy ?
SJuan76,

Odpowiedzi:

8

Jak mówi Telastyn, porównanie statycznych definicji funkcji:

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

do

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

Tak naprawdę nie straciłeś niczego, przechodząc od OOP do FP.

Jest to jednak tylko część historii, ponieważ funkcje i interfejsy są wymienione nie tylko w ich statycznych definicjach. Są także przekazywane . Powiedzmy, że nasz MessageQueryzostał odczytany przez inny fragment kodu, a MessageProcessor. Potem będzie:

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

Teraz nie możemy bezpośrednio zobaczyć nazwy metody IMessageQuery.Readani jej parametru int id, ale bardzo łatwo możemy się do niej dostać dzięki naszemu IDE. Mówiąc bardziej ogólnie, fakt, że przekazujemy IMessageQueryraczej niż dowolny interfejs za pomocą metody funkcji od int do string oznacza, że ​​zachowujemy idmetadane nazwy parametru związane z tą funkcją.

Z drugiej strony, dla naszej wersji funkcjonalnej mamy:

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

Co więc zatrzymaliśmy i straciliśmy? Cóż, nadal mamy nazwę parametru messageReader, co prawdopodobnie sprawia, że ​​nazwa typu (odpowiednik IMessageQuery) jest niepotrzebna. Ale teraz straciliśmy nazwę parametru idw naszej funkcji.


Są na to dwa główne sposoby:

  • Po pierwsze, po przeczytaniu tego podpisu możesz już całkiem dobrze zgadnąć, co się dzieje. Dzięki krótkim, prostym i spójnym funkcjom oraz dobrym nazewnictwu łatwiej jest intuicyjnie znaleźć te informacje. Gdy tylko zaczniemy czytać samą funkcję, będzie to jeszcze prostsze.

  • Po drugie, w wielu językach funkcjonalnych uważa się projektowanie idiomatyczne za tworzenie małych typów do zawijania prymitywów. W tym przypadku dzieje się odwrotnie - zamiast zamiany nazwy typu na nazwę parametru ( IMessageQuerydo messageReader) możemy zastąpić nazwę parametru nazwą typu. Na przykład intmoże być zawinięty w typ o nazwie Id:

    type Id = Id of int
    

    Teraz nasz readpodpis staje się:

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

    Co jest tak samo pouczające jak to, co mieliśmy wcześniej.

    Na marginesie, zapewnia to nam również pewną ochronę kompilatora, którą mieliśmy w OOP. Podczas gdy wersja OOP zapewniła, że ​​wybraliśmy konkretną, IMessageQuerya nie jakąkolwiek starą int -> stringfunkcję, tutaj mamy podobną (ale inną) ochronę, którą przyjmujemy Id -> stringraczej niż jakąkolwiek starą int -> string.


Nie chciałbym powiedzieć ze 100% pewnością, że te techniki zawsze będą tak samo dobre i pouczające, jak posiadanie pełnych informacji dostępnych w interfejsie, ale myślę, że z powyższych przykładów można powiedzieć, że przez większość czasu prawdopodobnie wykona równie dobrą robotę.

Ben Aaronson
źródło
1
To moja ulubiona odpowiedź - że FP zachęca do używania typów semantycznych
AlexFoxGill
10

Podczas wykonywania FP mam tendencję do używania bardziej specyficznych typów semantycznych.

Na przykład twoja metoda dla mnie mogłaby wyglądać następująco:

read: MessageId -> Message

To komunikuje o wiele więcej niż ThingDoer.doThing()styl OO (/ java)

Daenyth
źródło
2
+1. Ponadto można zakodować znacznie więcej właściwości w systemie typów w typach języków FP, takich jak Haskell. Gdyby to był typ haskell, wiedziałbym, że w ogóle nie robi żadnego we / wy (a zatem prawdopodobnie odwołuje się gdzieś w pamięci do mapowania identyfikatorów na wiadomości). W językach OOP nie mam tych informacji - ta funkcja może wywoływać bazę danych w ramach jej wywoływania.
Jack
1
Nie jestem do końca jasne, dlaczego miałoby to komunikować się bardziej niż styl Java. Co read: MessageId -> Messageci to string MessageReader.GetMessage(int messageId)mówi?
Ben Aaronson,
@BenAaronson stosunek sygnału do szumu jest lepszy. Jeśli jest to metoda instancji OO, nie mam pojęcia, co robi pod maską, może mieć jakąkolwiek zależność, która nie zostanie wyrażona. Jak wspomina Jack, gdy masz silniejsze typy, masz więcej zapewnień.
Daenyth,
9

Co zatem mogą zrobić funkcjonalni programiści, aby zachować semantykę dobrze nazwanego interfejsu?

Używaj dobrze nazwanych funkcji.

IMessageQuery::Read: int -> stringpo prostu staje się ReadMessageQuery: int -> stringlub coś podobnego.

Najważniejszą rzeczą do zapamiętania jest to, że nazwy są umowami tylko w najluzszym tego słowa znaczeniu. Działają tylko wtedy, gdy ty i inny programista wywnioskujecie te same implikacje z nazwy, i będziecie ich przestrzegać. Z tego powodu możesz naprawdę użyć dowolnej nazwy, która komunikuje to dorozumiane zachowanie. OO i programowanie funkcjonalne mają swoje nazwy w nieco innych miejscach i w nieco różnych kształtach, ale ich funkcja jest taka sama.

Czy kontrakt semantyczny interfejsu (OOP) ma więcej informacji niż podpis funkcji (FP)?

Nie w tym przykładzie. Jak wyjaśniłem powyżej, pojedyncza klasa / interfejs z pojedynczą funkcją nie jest znacznie bardziej pouczająca niż podobnie nazwana samodzielna funkcja.

Gdy uzyskasz więcej niż jedną funkcję / pole / właściwość w klasie, możesz wnioskować o nich więcej informacji, ponieważ możesz zobaczyć ich relację. Jest to dyskusyjne, jeśli jest to bardziej informacyjne niż samodzielne funkcje, które przyjmują te same / podobne parametry lub samodzielne funkcje zorganizowane według przestrzeni nazw lub modułu.

Osobiście nie sądzę, że OO jest znacznie bardziej pouczające, nawet w bardziej złożonych przykładach.

Telastyn
źródło
2
Co z nazwami parametrów?
Ben Aaronson,
@BenAaronson - co z nimi? Zarówno język OO, jak i języki funkcjonalne pozwalają określić nazwy parametrów, więc jest to push. Używam tylko skróconego podpisu typu, aby zachować spójność z pytaniem. W prawdziwym języku to będzie wyglądać mniej więcej takReadMessageQuery id = <code to go fetch based on id>
Telastyn
1
Cóż, jeśli funkcja przyjmuje interfejs jako parametr, a następnie wywołuje metodę na tym interfejsie, nazwy parametrów dla metody interfejsu są łatwo dostępne. Jeśli funkcja przyjmuje inną funkcję jako parametr, nie jest łatwo przypisać nazwy parametrów do tej przekazanej funkcji
Ben Aaronson
1
Tak, @BenAaronson jest to jedna z informacji zagubionych w sygnaturze funkcji. Na przykład w let consumer (messageQuery : int -> string) = messageQuery 5The idparametr jest po prostu int. Myślę, że jednym argumentem jest to, że powinieneś zdać Id, a nie int. W rzeczywistości byłaby to przyzwoita odpowiedź sama w sobie.
AlexFoxGill
@AlexFoxGill Właściwie właśnie pisałem coś w tym stylu!
Ben Aaronson
0

Nie zgadzam się, że żadna funkcja nie może mieć „kontraktu semantycznego”. Rozważ te prawa dla 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

W jakim sensie nie jest to semantyczna ani umowa? Nie musisz definiować typu „fałdera”, zwłaszcza, że foldrjest to jednoznacznie określone przez te prawa. Wiesz dokładnie, co to zrobi.

Jeśli chcesz przyjąć jakąś funkcję, możesz zrobić to samo:

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

Musisz tylko nazwać i uchwycić ten typ, jeśli potrzebujesz tej samej umowy wiele razy:

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

Sprawdzanie typów nie będzie wymuszać jakiejkolwiek semantyki przypisanej do typu, więc tworzenie nowego typu dla każdego kontraktu jest po prostu wzorcem.

Jonathan Cast
źródło
1
Nie sądzę, aby twój pierwszy punkt dotyczył pytania - jest to definicja funkcji, a nie podpis. Oczywiście, patrząc na implementację klasy lub funkcji, możesz powiedzieć, co ona robi - pytanie brzmi: czy nadal możesz zachować semantykę na poziomie abstrakcji (interfejs lub podpis funkcji)
AlexFoxGill
@AlexFoxGill - to nie definicja funkcji, choć nie jednoznacznie określić foldr. Wskazówka: definicja foldrma dwa równania (dlaczego?), A powyższa specyfikacja ma trzy.
Jonathan Cast
Chodzi mi o to, że foldr jest funkcją, a nie sygnaturą funkcji. Przepisy lub definicja nie są częścią podpisu
AlexFoxGill
-1

Prawie wszystkie statycznie wpisane języki funkcjonalne mają sposób na alias podstawowych typów w sposób, który wymaga jawnego zadeklarowania swojego semantycznego zamiaru. Niektóre inne odpowiedzi podały przykłady. W praktyce doświadczeni programiści funkcjonalni potrzebują bardzo dobrych powodów, aby korzystać z tych rodzajów opakowań, ponieważ szkodzą one możliwości łączenia i ponownego użycia.

Załóżmy na przykład, że klient chciał implementacji zapytania dotyczącego wiadomości, które zostało poparte listą. W Haskell implementacja może być tak prosta:

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

Korzystanie z newtype Message = Message Stringtego byłoby o wiele mniej proste, a implementacja wyglądałaby mniej więcej tak:

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

Może to nie wydawać się wielkim problemem, ale albo musisz przeprowadzić konwersję tego typu tam i z powrotem wszędzie , albo ustawić warstwę graniczną w kodzie, w której wszystko powyżej jest Int -> Stringnastępnie konwertowane, Id -> Messageaby przejść do warstwy poniżej. Powiedzmy, że chcę dodać internacjonalizację lub sformatować ją wielkimi literami, dodać kontekst rejestrowania lub cokolwiek innego. Wszystkie te operacje są bardzo proste do skomponowania za pomocą Int -> Stringi denerwujące za pomocą Id -> Message. Nie jest tak, że nigdy nie ma przypadków, w których pożądane są zwiększone ograniczenia typów, ale irytacja powinna być bardziej warta wymiany.

Możesz użyć synonimu typu zamiast opakowania (w Haskell typezamiast newtype), co jest znacznie bardziej powszechne i nie wymaga konwersji wszędzie, ale nie zapewnia statycznych typów gwarancji, takich jak wersja OOP, tylko trochę enapsulacji. Owijarki typów są najczęściej używane tam, gdzie klient nie powinien w ogóle manipulować wartością, wystarczy ją zapisać i przekazać z powrotem. Na przykład uchwyt pliku.

Nic nie stoi na przeszkodzie, aby klient był „wywrotowy”. Po prostu tworzysz obręcze do przeskoczenia dla każdego klienta. Prosta próbka do wspólnego testu jednostkowego często wymaga dziwnego zachowania, które nie ma sensu w produkcji. Twoje interfejsy powinny być napisane, aby ich to nie obchodziło, jeśli to w ogóle możliwe.

Karl Bielefeldt
źródło
1
To bardziej przypomina komentarz do odpowiedzi Ben / Daenyth - że możesz używać typów semantycznych, ale nie robisz tego z powodu niedogodności? Nie głosowałem za tobą, ale to nie jest odpowiedź na to pytanie.
AlexFoxGill
-1 Nie zaszkodzi to w żaden sposób kompozycyjności ani możliwości ponownego użycia. Jeśli Idi Messagesą prostymi opakowaniami dla Inti String, konwersja między nimi jest banalna .
Michael Shaw,
Tak, jest to trywialne, ale irytujące robić to wszędzie. Dodałem przykład i trochę opisujący różnicę między użyciem synonimów typów i opakowań.
Karl Bielefeldt,
To wydaje się być wielkością. W małych projektach tego rodzaju typy opakowań prawdopodobnie nie są już tak przydatne. W dużych projektach naturalne jest, że granice będą stanowić niewielką część całego kodu, więc wykonywanie tego typu konwersji na granicy, a następnie przekazywanie owiniętych typów wszędzie indziej nie jest zbyt uciążliwe. Ponadto, jak powiedział Alex, nie rozumiem, jak to jest odpowiedź na pytanie.
Ben Aaronson,
Wyjaśniłem, jak to zrobić, a następnie dlaczego programiści funkcjonalni tego nie zrobili. To odpowiedź, nawet jeśli ludzie nie chcieli tego. Nie ma to nic wspólnego z rozmiarem. Pomysł, że w jakiś sposób dobrze jest celowo utrudnić ponowne użycie kodu, jest zdecydowanie koncepcją OOP. Tworzysz typ produktu, który dodaje wartości? Cały czas. Czy tworzysz typ dokładnie taki jak typ powszechnie używany, z tym wyjątkiem, że możesz go używać tylko w jednej bibliotece? Praktycznie niespotykane, chyba że w kodzie FP, który silnie współdziała z kodem OOP, lub napisanym przez osobę znającą głównie idiomy OOP.
Karl Bielefeldt,