pomysł dopasowania przełącznika / wzorca

151

Ostatnio patrzyłem na język F # i chociaż nie zamierzam w najbliższym czasie przeskoczyć przez barierę, zdecydowanie podkreśla niektóre obszary, w których C # (lub obsługa bibliotek) może ułatwić życie.

W szczególności myślę o możliwości dopasowania wzorców w F #, która pozwala na bardzo bogatą składnię - znacznie bardziej wyrazistą niż bieżące odpowiedniki przełącznika / warunkowe C #. Nie będę próbował podawać bezpośredniego przykładu (mój F # nie jest do tego przystosowany), ale w skrócie pozwala:

  • dopasuj według typu (ze sprawdzaniem pełnego pokrycia dla związków rozłącznych) [uwaga: to również określa typ zmiennej powiązanej, dając dostęp do elementu członkowskiego itp.]
  • dopasować według predykatu
  • kombinacje powyższych (i być może kilka innych scenariuszy, których nie znam)

Chociaż byłoby cudownie, gdyby C # ostatecznie pożyczył [ahem] część tego bogactwa, w międzyczasie przyglądałem się temu, co można zrobić w czasie wykonywania - na przykład dość łatwo jest połączyć niektóre obiekty, aby umożliwić:

var getRentPrice = new Switch<Vehicle, int>()
        .Case<Motorcycle>(bike => 100 + bike.Cylinders * 10) // "bike" here is typed as Motorcycle
        .Case<Bicycle>(30) // returns a constant
        .Case<Car>(car => car.EngineType == EngineType.Diesel, car => 220 + car.Doors * 20)
        .Case<Car>(car => car.EngineType == EngineType.Gasoline, car => 200 + car.Doors * 20)
        .ElseThrow(); // or could use a Default(...) terminator

gdzie getRentPrice to Func <Vehicle, int>.

[uwaga - być może Switch / Case to niewłaściwe terminy ... ale pokazuje ideę]

Dla mnie jest to o wiele jaśniejsze niż ekwiwalent z użyciem powtórzonego if / else lub złożonego trójskładnikowego warunku (co staje się bardzo nieuporządkowane w przypadku nietrywialnych wyrażeń - mnóstwo nawiasów). Pozwala również uniknąć wielu rzutów i umożliwia proste rozszerzenie (bezpośrednio lub metodami rozszerzania) na bardziej szczegółowe dopasowania, na przykład dopasowanie InRange (...) porównywalne z VB Select ... Przypadek "x To y " stosowanie.

Po prostu próbuję ocenić, czy ludzie uważają, że konstrukcje takie jak powyżej dają wiele korzyści (przy braku obsługi języka)?

Uwaga dodatkowo, że grałem z 3 wariantami powyższego:

  • wersja Func <TSource, TValue> do oceny - porównywalna do złożonych trójskładnikowych instrukcji warunkowych
  • wersja Action <TSource> - porównywalna do if / else if / else if / else if / else
  • wersja Expression <Func <TSource, TValue >> - jako pierwsza, ale do wykorzystania przez dowolnych dostawców LINQ

Ponadto użycie wersji opartej na wyrażeniach umożliwia ponowne zapisywanie drzewa wyrażeń, zasadniczo umieszczając wszystkie gałęzie w jednym złożonym wyrażeniu warunkowym, zamiast używać wielokrotnego wywołania. Nie sprawdzałem ostatnio, ale w niektórych wczesnych kompilacjach Entity Framework wydaje mi się, że jest to konieczne, ponieważ nie bardzo lubił InvocationExpression. Pozwala również na bardziej wydajne użycie z LINQ-to-Objects, ponieważ pozwala uniknąć powtarzających się wywołań delegatów - testy pokazują dopasowanie podobne do powyższego (przy użyciu formularza Expression) działające z tą samą prędkością [w rzeczywistości nieznacznie szybsze] w porównaniu z równoważnym C # złożona instrukcja warunkowa. Aby zapewnić kompletność, wersja oparta na Func <...> zajęła 4 razy więcej czasu niż instrukcja warunkowa C #, ale nadal jest bardzo szybka i jest mało prawdopodobne, aby była głównym wąskim gardłem w większości przypadków użycia.

Z zadowoleniem przyjmuję wszelkie przemyślenia / wkład / krytykę / itp. Dotyczące powyższego (lub możliwości bogatszej obsługi języka C # ... mam nadzieję ;-p).

Marc Gravell
źródło
„Po prostu próbuję ocenić, czy ludzie uważają, że konstrukcje takie jak powyższe dają wiele korzyści (przy braku obsługi języka)?” IMHO, tak. Czy coś podobnego już nie istnieje? Jeśli nie, zachęcamy do napisania lekkiej biblioteki.
Konrad Rudolph
10
Możesz użyć VB .NET, który obsługuje to w instrukcji select case. Eek!
Jim Burger,
Będę też trąbić swój własny klakson i dodać link do mojej biblioteki: funkcjonalny dotnet
Alexey Romanov
1
Podoba mi się ten pomysł i tworzy bardzo ładną i znacznie bardziej elastyczną formę obudowy wyłącznika; Jednak czy nie jest to naprawdę upiększony sposób używania składni podobnej do Linqa jako opakowania typu if-then? Zniechęciłbym kogoś do używania tego zamiast prawdziwej transakcji, czyli switch-caseoświadczenia. Nie zrozum mnie źle, myślę, że ma swoje miejsce i prawdopodobnie będę szukał sposobu na wdrożenie.
IAbstract
2
Chociaż to pytanie ma ponad dwa lata, warto wspomnieć, że C # 7 pojawi się wkrótce (ish) z możliwościami dopasowywania wzorców.
Abion47

Odpowiedzi:

22

Wiem, że to stary temat, ale w c # 7 możesz:

switch(shape)
{
    case Circle c:
        WriteLine($"circle with radius {c.Radius}");
        break;
    case Rectangle s when (s.Length == s.Height):
        WriteLine($"{s.Length} x {s.Height} square");
        break;
    case Rectangle r:
        WriteLine($"{r.Length} x {r.Height} rectangle");
        break;
    default:
        WriteLine("<unknown shape>");
        break;
    case null:
        throw new ArgumentNullException(nameof(shape));
}
Marcus Pierce
źródło
Istotną różnicą między C # i F # jest kompletność dopasowania wzorca. To, że dopasowanie wzorca obejmuje wszystkie możliwe dostępne przypadki, w pełni opisane, ostrzeżenia kompilatora, jeśli tego nie zrobisz. Chociaż można słusznie argumentować, że robi to przypadek domyślny, w praktyce jest to również często wyjątek w czasie wykonywania.
VoronoiPotato
37

Po zrobieniu takich „funkcjonalnych” rzeczy w C # (a nawet przeczytaniu książki o tym), doszedłem do wniosku, że nie, z kilkoma wyjątkami, takie rzeczy nie pomagają zbytnio.

Głównym powodem jest to, że języki takie jak F # czerpią dużą moc z prawdziwego wspierania tych funkcji. Nie „dasz radę”, ale „to proste, jasne, oczekuje się”.

Na przykład podczas dopasowywania wzorców kompilator informuje Cię, czy jest niekompletne dopasowanie lub kiedy inne dopasowanie nigdy nie zostanie trafione. Jest to mniej przydatne w przypadku typów otwartych, ale przy dopasowywaniu unii dyskryminowanej lub krotek jest bardzo sprytne. W języku F # oczekujesz, że ludzie będą dopasowywać wzorce i natychmiast ma to sens.

„Problem” polega na tym, że kiedy zaczniesz używać pewnych koncepcji funkcjonalnych, naturalna jest chęć kontynuowania. Jednak wykorzystanie krotek, funkcji, częściowego stosowania metod i curry, dopasowywania wzorców, funkcji zagnieżdżonych, typów ogólnych, obsługi monad itp. W C # staje się bardzo brzydkie, bardzo szybko. To zabawne, a niektórzy bardzo mądrzy ludzie zrobili kilka bardzo fajnych rzeczy w C #, ale w rzeczywistości używanie go wydaje się ciężkie.

Czego często używałem (między projektami) w C #:

  • Funkcje sekwencyjne za pośrednictwem metod rozszerzających dla IEnumerable. Rzeczy takie jak ForEach lub Process („Zastosuj”? - wykonaj akcję na elemencie sekwencji, ponieważ jest wyliczany) pasują, ponieważ składnia C # dobrze ją obsługuje.
  • Abstrakcja typowych wzorców wypowiedzi. Skomplikowane bloki try / catch / final lub inne (często bardzo ogólne) bloki kodu. Rozszerzanie LINQ-to-SQL również pasuje tutaj.
  • Do pewnego stopnia krotki.

** Należy jednak pamiętać: brak automatycznego uogólniania i wnioskowania o typie naprawdę utrudnia korzystanie z tych funkcji. **

Wszystko to powiedziane, jak ktoś wspomniał, w małym zespole, w określonym celu, tak, być może mogą pomóc, jeśli utkniesz z C #. Ale z mojego doświadczenia wynika, że ​​zwykle wydawały się bardziej kłopotliwe, niż były warte - YMMV.

Inne linki:

MichaelGG
źródło
25

Prawdopodobnie powodem, dla którego C # nie ułatwia włączania typu, jest to, że jest to przede wszystkim język zorientowany obiektowo, a `` poprawnym '' sposobem zrobienia tego w kategoriach obiektowych byłoby zdefiniowanie metody GetRentPrice w Vehicle i zastąpić go w klasach pochodnych.

To powiedziawszy, spędziłem trochę czasu bawiąc się wieloparadygmatami i językami funkcjonalnymi, takimi jak F # i Haskell, które mają tego typu możliwości i natknąłem się na wiele miejsc, w których byłoby to przydatne wcześniej (np. nie piszesz typów, które musisz włączyć, więc nie możesz zaimplementować na nich metody wirtualnej) i jest to coś, co chciałbym w tym języku wraz z dyskryminowanymi związkami.

[Edycja: Usunięto część dotyczącą wydajności, ponieważ Marc wskazał, że może to być zwarcie]

Innym potencjalnym problemem jest użyteczność - z końcowego połączenia jasno wynika, co się stanie, jeśli mecz nie spełni żadnych warunków, ale jakie jest zachowanie, jeśli spełnia dwa lub więcej warunków? Czy powinien zgłosić wyjątek? Czy powinien zwrócić pierwszy czy ostatni mecz?

Sposobem, którego używam do rozwiązywania tego rodzaju problemów, jest użycie pola słownika z typem jako kluczem i lambdą jako wartością, co jest dość zwięzłe do skonstruowania przy użyciu składni inicjatora obiektu; jednak uwzględnia to tylko konkretny typ i nie zezwala na dodatkowe predykaty, więc może nie być odpowiednie dla bardziej złożonych przypadków. [Uwaga dodatkowa - jeśli spojrzysz na dane wyjściowe kompilatora C #, często konwertuje on instrukcje przełączania na tabele skoku oparte na słownikach, więc nie wydaje się, aby istniał dobry powód, dla którego nie mógł obsługiwać przełączania typów]

Greg Beech
źródło
1
Właściwie - wersja, którą mam, ma zwarcia zarówno w wersji delegatowej, jak i ekspresyjnej. Wersja wyrażenia kompiluje się do złożonego warunku; wersja delegata to po prostu zestaw predykatów i funkcji / akcji - po dopasowaniu zatrzymuje się.
Marc Gravell
Interesujące - z pobieżnego spojrzenia założyłem, że będzie musiał wykonać przynajmniej podstawowe sprawdzenie każdego warunku, ponieważ wyglądał jak łańcuch metod, ale teraz zdaję sobie sprawę, że metody w rzeczywistości łączą instancję obiektu w łańcuch, aby go zbudować, abyś mógł to zrobić. Zmienię odpowiedź, aby usunąć to stwierdzenie.
Greg Beech
22

Nie sądzę, aby tego rodzaju biblioteki (działające jak rozszerzenia językowe) zyskały powszechną akceptację, ale są zabawne i mogą być naprawdę przydatne dla małych zespołów pracujących w określonych domenach, w których jest to przydatne. Na przykład, jeśli piszesz mnóstwo `` reguł biznesowych / logiki '', które wykonują dowolne testy typu, takie jak ten i inne, widzę, jak byłoby to przydatne.

Nie mam pojęcia, czy kiedykolwiek będzie to funkcja języka C # (wydaje się wątpliwa, ale kto może przewidzieć przyszłość?).

Dla porównania odpowiedni F # to w przybliżeniu:

let getRentPrice (v : Vehicle) = 
    match v with
    | :? Motorcycle as bike -> 100 + bike.Cylinders * 10
    | :? Bicycle -> 30
    | :? Car as car when car.EngineType = Diesel -> 220 + car.Doors * 20
    | :? Car as car when car.EngineType = Gasoline -> 200 + car.Doors * 20
    | _ -> failwith "blah"

zakładając, że zdefiniowałbyś hierarchię klas zgodnie z liniami

type Vehicle() = class end

type Motorcycle(cyl : int) = 
    inherit Vehicle()
    member this.Cylinders = cyl

type Bicycle() = inherit Vehicle()

type EngineType = Diesel | Gasoline

type Car(engType : EngineType, doors : int) = 
    inherit Vehicle()
    member this.EngineType = engType
    member this.Doors = doors
Brian
źródło
2
Dzięki za wersję F #. Wydaje mi się, że podoba mi się sposób, w jaki F # sobie z tym radzi, ale nie jestem pewien, czy (ogólnie) F # jest w tej chwili właściwym wyborem, więc muszę przejść ten środek ...
Marc Gravell
13

Aby odpowiedzieć na twoje pytanie, tak, myślę, że konstrukcje składniowe dopasowywania wzorców są przydatne. Na przykład chciałbym zobaczyć obsługę składniową w C # dla tego.

Oto moja implementacja klasy, która zapewnia (prawie) taką samą składnię, jak opisujesz

public class PatternMatcher<Output>
{
    List<Tuple<Predicate<Object>, Func<Object, Output>>> cases = new List<Tuple<Predicate<object>,Func<object,Output>>>();

    public PatternMatcher() { }        

    public PatternMatcher<Output> Case(Predicate<Object> condition, Func<Object, Output> function)
    {
        cases.Add(new Tuple<Predicate<Object>, Func<Object, Output>>(condition, function));
        return this;
    }

    public PatternMatcher<Output> Case<T>(Predicate<T> condition, Func<T, Output> function)
    {
        return Case(
            o => o is T && condition((T)o), 
            o => function((T)o));
    }

    public PatternMatcher<Output> Case<T>(Func<T, Output> function)
    {
        return Case(
            o => o is T, 
            o => function((T)o));
    }

    public PatternMatcher<Output> Case<T>(Predicate<T> condition, Output o)
    {
        return Case(condition, x => o);
    }

    public PatternMatcher<Output> Case<T>(Output o)
    {
        return Case<T>(x => o);
    }

    public PatternMatcher<Output> Default(Func<Object, Output> function)
    {
        return Case(o => true, function);
    }

    public PatternMatcher<Output> Default(Output o)
    {
        return Default(x => o);
    }

    public Output Match(Object o)
    {
        foreach (var tuple in cases)
            if (tuple.Item1(o))
                return tuple.Item2(o);
        throw new Exception("Failed to match");
    }
}

Oto kod testowy:

    public enum EngineType
    {
        Diesel,
        Gasoline
    }

    public class Bicycle
    {
        public int Cylinders;
    }

    public class Car
    {
        public EngineType EngineType;
        public int Doors;
    }

    public class MotorCycle
    {
        public int Cylinders;
    }

    public void Run()
    {
        var getRentPrice = new PatternMatcher<int>()
            .Case<MotorCycle>(bike => 100 + bike.Cylinders * 10) 
            .Case<Bicycle>(30) 
            .Case<Car>(car => car.EngineType == EngineType.Diesel, car => 220 + car.Doors * 20)
            .Case<Car>(car => car.EngineType == EngineType.Gasoline, car => 200 + car.Doors * 20)
            .Default(0);

        var vehicles = new object[] {
            new Car { EngineType = EngineType.Diesel, Doors = 2 },
            new Car { EngineType = EngineType.Diesel, Doors = 4 },
            new Car { EngineType = EngineType.Gasoline, Doors = 3 },
            new Car { EngineType = EngineType.Gasoline, Doors = 5 },
            new Bicycle(),
            new MotorCycle { Cylinders = 2 },
            new MotorCycle { Cylinders = 3 },
        };

        foreach (var v in vehicles)
        {
            Console.WriteLine("Vehicle of type {0} costs {1} to rent", v.GetType(), getRentPrice.Match(v));
        }
    }
cdiggins
źródło
9

Dopasowanie wzorców (zgodnie z opisem tutaj ), jego celem jest dekonstrukcja wartości zgodnie z ich specyfikacją typu. Jednak koncepcja klasy (lub typu) w C # nie zgadza się z tobą.

Nie ma nic złego w projektowaniu języka z wieloma paradygmatami, wręcz przeciwnie, bardzo fajnie jest mieć lambdy w C #, a Haskell może robić niezbędne rzeczy, np. IO. Ale to niezbyt eleganckie rozwiązanie, nie w stylu Haskella.

Ale ponieważ sekwencyjne języki programowania proceduralnego można rozumieć w kategoriach rachunku lambda, a C # tak się składa, że ​​dobrze pasuje do parametrów sekwencyjnego języka proceduralnego, jest to dobre dopasowanie. Ale wzięcie czegoś z czystego kontekstu funkcjonalnego, powiedzmy Haskell, a następnie umieszczenie tej cechy w języku, który nie jest czysty, cóż, zrobienie tego, nie gwarantuje lepszego wyniku.

Chodzi mi o to, że to, co sprawia, że ​​dopasowywanie wzorców działa tak, jest związane z projektem języka i modelem danych. Powiedziawszy to, nie uważam, aby dopasowywanie wzorców było użyteczną funkcją języka C #, ponieważ nie rozwiązuje ono typowych problemów języka C # ani nie pasuje do imperatywnego paradygmatu programowania.

John Leidegren
źródło
1
Może. Rzeczywiście, trudno byłoby mi wymyślić przekonujący argument „zabójcy” uzasadniający jego potrzebę (w przeciwieństwie do „być może miłego w kilku skrajnych przypadkach kosztem uczynienia języka bardziej złożonym”).
Marc Gravell
5

IMHO, sposobem OO robienia takich rzeczy jest wzorzec gościa. Twoje metody składowe gościa działają po prostu jako konstrukcje wielkości liter i pozwalasz samemu językowi obsługiwać odpowiednie wywołanie bez konieczności „podglądania” typów.

Bacila
źródło
4

Chociaż nie jest to bardzo „C-sharpey”, aby włączyć typ, wiem, że konstrukt byłby całkiem pomocny w ogólnym użyciu - mam co najmniej jeden osobisty projekt, który mógłby go używać (chociaż jest to zarządzalny bankomat). Czy jest dużo problemów z wydajnością kompilacji, z ponownym zapisywaniem drzewa wyrażeń?

Simon Buchan
źródło
Nie, jeśli buforujesz obiekt do ponownego użycia (co jest w dużej mierze sposobem działania wyrażeń lambda w języku C #, z wyjątkiem tego, że kompilator ukrywa kod). Ponowne napisanie zdecydowanie poprawia skompilowaną wydajność - jednak do regularnego użytku (zamiast LINQ-to-Something) oczekuję, że wersja delegata może być bardziej przydatna.
Marc Gravell
Zwróć też uwagę - niekoniecznie jest to typ włączania - może być również używany jako złożony warunek (nawet przez LINQ) - ale bez bałaganu x => Test? Wynik1: (Test2? Wynik2: (Test3? Wynik 3: Wynik4))
Marc Gravell
Dobrze wiedzieć, chociaż miałem na myśli wydajność rzeczywistej kompilacji : jak długo trwa csc.exe - nie jestem wystarczająco zaznajomiony z C #, aby wiedzieć, czy kiedykolwiek jest to naprawdę problem, ale jest to duży problem dla C ++.
Simon Buchan
csc nie będzie migać - jest tak podobny do tego, jak działa LINQ, a kompilator C # 3.0 jest całkiem dobry w LINQ / metodach rozszerzających itp.
Marc Gravell
3

Myślę, że wygląda to naprawdę interesująco (+1), ale jedna rzecz, na którą należy uważać: kompilator C # całkiem dobrze optymalizuje instrukcje przełącznika. Nie tylko do zwarcia - otrzymujesz zupełnie inną IL w zależności od liczby przypadków i tak dalej.

Twój konkretny przykład robi coś, co uważam za bardzo przydatne - nie ma odpowiednika składni dla wielkości liter według typu, ponieważ (na przykład) typeof(Motorcycle)nie jest stałą.

To staje się bardziej interesujące w dynamicznych aplikacjach - twoja logika może być łatwo oparta na danych, dając wykonanie w stylu "silnika reguł".

Keith
źródło
0

Możesz osiągnąć to, czego szukasz, korzystając z biblioteki, którą napisałem o nazwie OneOf

Główną zaletą w stosunku do switch(i ifi exceptions as control flow) jest to, że jest bezpieczny w czasie kompilacji - nie ma domyślnej obsługi ani nie działa

   OneOf<Motorcycle, Bicycle, Car> vehicle = ... //assign from one of those types
   var getRentPrice = vehicle
        .Match(
            bike => 100 + bike.Cylinders * 10, // "bike" here is typed as Motorcycle
            bike => 30, // returns a constant
            car => car.EngineType.Match(
                diesel => 220 + car.Doors * 20
                petrol => 200 + car.Doors * 20
            )
        );

Jest na Nuget i celuje w net451 i netstandard1.6

mcintyre321
źródło