Czy dopasowanie wzorców do typów jest idiomatyczne lub źle zaprojektowane?

18

Wygląda na to, że kod F # często wzoruje dopasowania względem typów. Na pewno

match opt with 
| Some val -> Something(val) 
| None -> Different()

wydaje się powszechne.

Ale z punktu widzenia OOP wygląda to bardzo podobnie do przepływu kontroli opartego na sprawdzeniu typu środowiska wykonawczego, na którym zwykle nie można się zgodzić. Aby to przeliterować, w OOP prawdopodobnie wolisz użyć przeciążenia:

type T = 
    abstract member Route : unit -> unit

type Foo() = 
    interface T with
        member this.Route() = printfn "Go left"

type Bar() = 
    interface T with
        member this.Route() = printfn "Go right"

To z pewnością więcej kodu. OTOH, moim zdaniem OOP-y ma zalety strukturalne:

  • rozszerzenie do nowej formy Tjest łatwe;
  • Nie muszę się martwić znalezieniem duplikatu przepływu sterowania wyborem trasy; i
  • wybór trasy jest niezmienny w tym sensie, że kiedy mam Foopod ręką, nigdy nie muszę się martwić Bar.Route()implementacją

Czy są zalety dopasowywania wzorców do typów, których nie widzę? Czy jest to uważane za idiomatyczne, czy może nie jest powszechnie używane?

Larry OBrien
źródło
4
Jaki sens ma widok języka funkcjonalnego z perspektywy OOP? W każdym razie prawdziwa moc dopasowywania wzorów pochodzi z zagnieżdżonych wzorów. Sprawdzanie najbardziej zewnętrznego konstruktora jest możliwe, ale w żadnym wypadku nie cała historia.
Ingo
To - But from an OOP perspective, that looks an awful lot like control-flow based on a runtime type check, which would typically be frowned on.- brzmi zbyt dogmatyczne. Czasami chcesz oddzielić swoje operacje od swojej hierarchii: może 1) nie możesz dodać operacji do hierarchii b / c, której nie posiadasz; 2) klasy, które chcesz mieć, nie pasują do twojej hierarchii; 3) możesz dodać op do swojej hierarchii, ale nie chcesz b / c, że nie chcesz zaśmiecać interfejsu API swojej hierarchii bzdurami, których większość klientów nie używa.
4
Tylko dla wyjaśnienia Somei Nonenie są typami. Obaj są konstruktorami, których typy są forall a. a -> option ai forall a. option a(przepraszam, nie jestem pewien, jaka jest składnia adnotacji typu w F #).

Odpowiedzi:

21

Masz rację, ponieważ hierarchie klas OOP są bardzo ściśle powiązane z dyskryminowanymi związkami w F # i że dopasowanie wzorca jest bardzo ściśle związane z dynamicznymi testami typu. W rzeczywistości tak właśnie F # kompiluje dyskryminowane związki do .NET!

Jeśli chodzi o rozszerzalność, istnieją dwie strony problemu:

  • OO pozwala dodawać nowe podklasy, ale utrudnia dodawanie nowych (wirtualnych) funkcji
  • FP pozwala dodawać nowe funkcje, ale utrudnia dodawanie nowych przypadków związków

To powiedziawszy, F # wyświetli ostrzeżenie, gdy przegapisz przypadek w dopasowaniu wzorca, więc dodanie nowych spraw związkowych nie jest wcale takie złe.

Jeśli chodzi o znajdowanie duplikatów podczas wybierania katalogu głównego - F # wyświetli ostrzeżenie, gdy masz dopasowanie, które jest zduplikowane, np .:

match x with
| Some foo -> printfn "first"
| Some foo -> printfn "second" // Warning on this line as it cannot be matched
| None -> printfn "third"

Problemem może być również fakt, że „wybór trasy jest niezmienny”. Na przykład, jeśli chcesz udostępnić implementację funkcji między przypadkami Fooi Bar, ale zrobić coś innego dla Zoosprawy, możesz to łatwo zakodować za pomocą dopasowania wzorca:

match x with
| Foo y | Bar y -> y * 20
| Zoo y -> y * 30

Zasadniczo FP skupia się bardziej na projektowaniu typów, a następnie dodawaniu funkcji. Dlatego naprawdę korzysta z faktu, że możesz dopasować swoje typy (model domeny) w kilku liniach w jednym pliku, a następnie łatwo dodać funkcje, które działają na modelu domeny.

Oba podejścia - OO i FP - są dość komplementarne i oba mają zalety i wady. Trudną rzeczą (pochodzącą z perspektywy OO) jest to, że F # zwykle używa stylu FP jako domyślnego. Ale jeśli naprawdę jest więcej potrzeby dodawania nowych podklas, zawsze możesz użyć interfejsów. Ale w większości systemów musisz również dodać typy i funkcje, więc wybór naprawdę nie ma aż tak wielkiego znaczenia - a używanie dyskryminowanych związków w F # jest przyjemniejsze.

Polecam tę wspaniałą serię blogów, aby uzyskać więcej informacji.

Tomas Petricek
źródło
3
Chociaż masz rację, chciałbym dodać, że nie jest to tak bardzo kwestia OO vs. FP, ale kwestia obiektów vs. typów sum. Poza obsesją OOP na ich temat, w przedmiotach nie ma nic, co czyni je niefunkcjonalnymi. A jeśli przeskoczysz przez wystarczającą liczbę obręczy, możesz zaimplementować typy sum w głównych językach OOP (choć nie będzie to ładne).
Doval
1
„A jeśli przeskoczysz przez wystarczającą liczbę obręczy, możesz zaimplementować typy sum również w głównych językach OOP (choć nie będzie to ładne)”. -> Myślę, że skończysz z czymś podobnym do tego, w jaki sposób typy F # są kodowane w systemie typów .NET :)
Tarmil
8

Prawidłowo zaobserwowałeś, że dopasowanie wzorca (zasadniczo instrukcja doładowania przełącznika) i dynamiczne wysyłanie mają podobieństwa. Współistnieją również w niektórych językach, z bardzo przyjemnym rezultatem. Istnieją jednak niewielkie różnice.

Mógłbym użyć systemu typów do zdefiniowania typu, który może mieć tylko określoną liczbę podtypów:

// pseudocode
data Bool = False | True
data Option a = None | Some item:a
data Tree a = Leaf item:a | Node (left:Tree a) (right:Tree a)

Nie będzie nigdy być inny podtyp Boollub Option, tak subclassing nie wydaje się być przydatne (niektóre języki, takie jak Scala mieć pojęcie instacji że można sobie z tym poradzić - klasa może być oznaczona jako „ostatecznego” na zewnątrz bieżącej jednostki kompilacji, ale podtypy można zdefiniować w tej jednostce kompilacji).

Ponieważ podtypy tego typu Optionsą teraz znane statycznie , kompilator może ostrzec, jeśli zapomnimy obsłużyć wielkość liter w naszym dopasowaniu wzorca. Oznacza to, że dopasowanie wzorca przypomina bardziej specjalną obniżkę, która zmusza nas do obsługi wszystkich opcji.

Ponadto dynamiczne wysyłanie metod (które jest wymagane w przypadku OOP) oznacza również sprawdzanie typu środowiska wykonawczego, ale innego rodzaju. Dlatego nie ma znaczenia, czy sprawdzanie tego typu odbywa się jawnie poprzez dopasowanie wzorca lub pośrednio poprzez wywołanie metody.

amon
źródło
„Oznacza to, że dopasowanie wzorca jest bardziej jak specjalny spowolnienie, które zmusza nas do obsługi wszystkich opcji” - w rzeczywistości uważam, że (dopóki dopasowujesz tylko konstruktory, a nie wartości lub struktura zagnieżdżona), jest to izomorficzne umieszczenie abstrakcyjnej metody wirtualnej w nadklasie.
Jules
2

Dopasowywanie wzorca F # zwykle wykonuje się przy użyciu dyskryminowanego związku, a nie klas (a zatem technicznie nie jest to w ogóle kontrola typu). Dzięki temu kompilator może ostrzec Cię, gdy nie uwzględniłeś przypadków w dopasowaniu wzorca.

Inną rzeczą wartą uwagi jest to, że w stylu funkcjonalnym organizujesz rzeczy według funkcjonalności, a nie według danych, więc dopasowanie wzorca pozwala zebrać różne funkcje w jednym miejscu, a nie rozproszone po klasach. Ma to również tę zaletę, że można zobaczyć, jak obsługiwane są inne sprawy tuż obok miejsca, w którym należy wprowadzić zmiany.

Dodanie nowej opcji wygląda wtedy następująco:

  1. Dodaj nową opcję do swojego dyskryminowanego związku
  2. Napraw wszystkie ostrzeżenia dotyczące niepełnych dopasowań wzorów
Nie dotyczy
źródło
2

Częściowo częściej widzisz to w programowaniu funkcjonalnym, ponieważ częściej używasz typów do podejmowania decyzji. Zdaję sobie sprawę, że prawdopodobnie wybrałeś przykłady mniej więcej przypadkowo, ale OOP odpowiadające przykładowi dopasowania wzorca częściej wyglądałoby:

if (opt != null)
    opt.Something()
else
    Different()

Innymi słowy, stosunkowo rzadko stosuje się polimorfizm, aby uniknąć rutynowych czynności, takich jak sprawdzanie wartości zerowej w OOP. Podobnie jak programista OO nie tworzy pustego obiektu w każdej małej sytuacji, funkcjonalny programista nie zawsze przeciąża funkcję, zwłaszcza gdy wiesz, że twoja lista wzorców jest wyczerpująca. Jeśli użyjesz systemu typów w większej liczbie sytuacji, zobaczysz, że jest używany w sposób, do którego nie jesteś przyzwyczajony.

I odwrotnie, idiomatyczne programowanie funkcjonalne odpowiadające przykładowi OOP najprawdopodobniej nie użyłoby dopasowania wzorca, ale miałoby fooRoutei barRoutefunkcje, które byłyby przekazywane jako argumenty do kodu wywołującego. Jeśli ktoś użyje dopasowania wzorca w takiej sytuacji, zwykle będzie to uważane za złe, tak jak ktoś, kto włącza typy, będzie uważane za nieprawidłowe w OOP.

Kiedy więc dopasowanie wzorca uważa się za dobry kod programowania funkcjonalnego? Gdy robisz więcej niż tylko patrzysz na typy i rozszerzasz wymagania, nie będziesz musiał dodawać więcej przypadków. Na przykład, Some valnie tylko sprawdza, czy optma typ Some, ale także wiąże valsię z typem podstawowym w celu bezpiecznego użycia typu po drugiej stronie ->. Wiesz, że najprawdopodobniej nigdy nie będziesz potrzebować trzeciej skrzynki, więc jest to dobre zastosowanie.

Dopasowywanie wzorców może powierzchownie przypominać obiektową instrukcję przełączania, ale dzieje się o wiele więcej, szczególnie w przypadku dłuższych lub zagnieżdżonych wzorców. Upewnij się, że bierzesz pod uwagę wszystko, co robi, zanim zadeklarujesz, że jest to odpowiednik źle zaprojektowanego kodu OOP. Często zwięźle radzi sobie z sytuacją, której nie da się w czysty sposób przedstawić w hierarchii dziedziczenia.

Karl Bielefeldt
źródło
Wiem, że o tym wiesz, i prawdopodobnie wpadło ci to w pamięć podczas pisania odpowiedzi, ale zauważ to Somei Nonenie są typami, więc nie dopasowujesz wzorów do typów. Dopasowujesz wzorce do konstruktorów tego samego typu . To nie jest tak, jakby pytać „instanceof”.
Andres F.,