Udostępnij za pośrednictwem


Zmiany dopasowania wzorca dla języka C# 9.0

Notatka

Ten artykuł jest specyfikacją funkcji. Specyfikacja służy jako dokument projektowy dla funkcji. Zawiera proponowane zmiany specyfikacji wraz z informacjami wymaganymi podczas projektowania i opracowywania funkcji. Te artykuły są publikowane do momentu sfinalizowania proponowanych zmian specyfikacji i włączenia ich do obecnej specyfikacji ECMA.

Mogą wystąpić pewne rozbieżności między specyfikacją funkcji a ukończoną implementacją. Te różnice są przechwytywane w odpowiednich spotkania projektowego języka (LDM).

Więcej informacji na temat procesu wdrażania specyfikacji funkcji można znaleźć w standardzie języka C# w artykule dotyczącym specyfikacji .

Rozważamy niewielką liczbę ulepszeń dopasowywania wzorców dla języka C# 9.0, które mają naturalną synergię i działają dobrze, aby rozwiązać szereg typowych problemów programistycznych:

Wzorce z nawiasami

Wzorce nawiasów umożliwiają programisty umieszczanie nawiasów wokół dowolnego wzorca. Nie jest to aż tak przydatne w przypadku istniejących wzorców w języku C# 8.0, jednak nowe kombinatory wzorców wprowadzają pierwszeństwo, które programista może chcieć zastąpić.

primary_pattern
    : parenthesized_pattern
    | // all of the existing forms
    ;
parenthesized_pattern
    : '(' pattern ')'
    ;

Wzorce typów

Dopuszczamy użycie typu jako wzorca.

primary_pattern
    : type-pattern
    | // all of the existing forms
    ;
type_pattern
    : type
    ;

To przekształca istniejące wyrażenie jest-wyrażeniem-typu na jest-wyrażeniem-wzorca, w którym wzorzec jest wzorcem-typu, chociaż nie zmienilibyśmy drzewa składni generowanego przez kompilator.

Jednym z subtelnych problemów z implementacją jest to, że ta gramatyka jest niejednoznaczna. Ciąg, taki jak a.b, można przeanalizować jako kwalifikowaną nazwę (w kontekście typu) lub wyrażenie kropkowane (w kontekście wyrażenia). Kompilator jest już w stanie traktować kwalifikowaną nazwę tak samo jak wyrażenie kropkowane w celu obsługi czegoś takiego jak e is Color.Red. Semantyczna analiza kompilatora zostałaby dodatkowo rozszerzona, aby móc powiązać wzorzec stały (składniowy) (np. wyrażenie kropkowane) jako typ, aby traktować go jako wzorzec typu powiązanego w celu obsługi tej konstrukcji.

Po tej zmianie będzie można napisać

void M(object o1, object o2)
{
    var t = (o1, o2);
    if (t is (int, string)) {} // test if o1 is an int and o2 is a string
    switch (o1) {
        case int: break; // test if o1 is an int
        case System.String: break; // test if o1 is a string
    }
}

Wzorce relacyjne

Wzorce relacyjne umożliwiają programistom wyrażenie, że wartość wejściowa musi spełniać ograniczenie relacyjne w porównaniu z wartością stałą:

    public static LifeStage LifeStageAtAge(int age) => age switch
    {
        < 0 =>  LifeStage.Prenatal,
        < 2 =>  LifeStage.Infant,
        < 4 =>  LifeStage.Toddler,
        < 6 =>  LifeStage.EarlyChild,
        < 12 => LifeStage.MiddleChild,
        < 20 => LifeStage.Adolescent,
        < 40 => LifeStage.EarlyAdult,
        < 65 => LifeStage.MiddleAdult,
        _ =>    LifeStage.LateAdult,
    };

Wzorce relacyjne obsługują operatory relacyjne <, <=, >i >= na wszystkich wbudowanych typach, które obsługują takie operatory relacyjne binarne z dwoma operandami tego samego typu w wyrażeniu. W szczególności obsługujemy wszystkie te wzorce relacyjne dla sbyte, byte, short, ushort, int, uint, long, ulong, char, float, double, decimal, ninti nuint.

primary_pattern
    : relational_pattern
    ;
relational_pattern
    : '<' relational_expression
    | '<=' relational_expression
    | '>' relational_expression
    | '>=' relational_expression
    ;

Wyrażenie jest wymagane do obliczenia wartości stałej. Jest to błąd, jeśli wartość stała jest double.NaN lub float.NaN. Jest to błąd, jeśli wyrażenie jest stałą null.

Gdy dane wejściowe są typem, dla którego zdefiniowany jest odpowiedni wbudowany operator relacyjny binarny, mający zastosowanie z danymi wejściowymi jako lewym operatorem, a daną stałą jako prawym operatorem, wynik działania tego operatora jest traktowany jako znaczenie wzorca relacyjnego. W przeciwnym razie konwertujemy dane wejściowe na typ wyrażenia przy użyciu jawnej konwersji dopuszczającej wartość null lub rozpatłaniającej. Jest to błąd czasu kompilacji, jeśli taka konwersja nie istnieje. Wzorzec jest uznawany za niezgodny, jeśli konwersja nie powiedzie się. Jeśli konwersja powiedzie się, wynikiem operacji dopasowywania wzorca jest ocena wyrażenia e OP v gdzie e jest przekonwertowanym wejściem, OP jest operatorem relacyjnym, a v jest wyrażeniem stałym.

Kombinatory wzorców

Kombinatory wzorca pozwalają na dopasowanie obu różnych wzorców przy użyciu and (można to rozszerzyć na dowolną liczbę wzorców przez wielokrotne użycie and), jednego z dwóch różnych wzorców przy użyciu or (tak samo), lub negacji wzorca przy użyciu not.

Typowym zastosowaniem kombinatora będzie idiom

if (e is not null) ...

Bardziej czytelny niż obecny idiom e is object, ten wzorzec jasno pokazuje, że chodzi o sprawdzenie, czy wartość jest różna od null.

Kombinatory and i or będą przydatne do testowania zakresów wartości

bool IsLetter(char c) => c is >= 'a' and <= 'z' or >= 'A' and <= 'Z';

W tym przykładzie pokazano, że and będzie mieć wyższy priorytet analizowania (tj. będzie wiązać się ściślej) niż or. Programista może użyć wzorca nawiasów, aby jawnie określić pierwszeństwo:

bool IsLetter(char c) => c is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z');

Podobnie jak wszystkie wzorce, te kombinatory mogą być używane w dowolnym kontekście, w którym oczekiwano wzorca, w tym zagnieżdżone wzorce, wyrażenie-wzorzec-wzorzec, wyrażenie-przełącznikai wzorzec etykiety wielkości liter instrukcji switch.

pattern
    : disjunctive_pattern
    ;
disjunctive_pattern
    : disjunctive_pattern 'or' conjunctive_pattern
    | conjunctive_pattern
    ;
conjunctive_pattern
    : conjunctive_pattern 'and' negated_pattern
    | negated_pattern
    ;
negated_pattern
    : 'not' negated_pattern
    | primary_pattern
    ;
primary_pattern
    : // all of the patterns forms previously defined
    ;

Przejdź do 6.2.5 Niejasności gramatyczne

Ze względu na wprowadzenie wzorca typu , typ ogólny może pojawić się przed tokenem =>. Dodajemy więc => do zestawu tokenów wymienionych w §6.2.5 Niejednoznaczności gramatyczne, aby umożliwić rozważenie niejednoznaczności < rozpoczynającej listę argumentów typu. Zobacz również https://github.com/dotnet/roslyn/issues/47614.

Otwarte problemy z proponowanymi zmianami

Składnia dla operatorów relacyjnych

Czy and, ori not jakiś kontekstowe słowo kluczowe? Jeśli tak, istnieje zmiana wprowadzająca niezgodność (np. w porównaniu z ich użyciem jako znacznik w wzorcu deklaracyjnym).

Semantyka (np. typ) dla operatorów relacyjnych

Oczekujemy obsługi wszystkich typów pierwotnych, które można porównać w wyrażeniu przy użyciu operatora relacyjnego. Znaczenie w prostych przypadkach jest jasne

bool IsValidPercentage(int x) => x is >= 0 and <= 100;

Ale jeśli dane wejściowe nie są takim typem pierwotnym, jaki typ próbujemy przekonwertować na?

bool IsValidPercentage(object x) => x is >= 0 and <= 100;

Zaproponowaliśmy, aby gdy typ danych wejściowych jest już porównywalnym elementem pierwotnym, jest to typ porównania. Jeśli jednak dane wejściowe nie są porównywalnym elementem pierwotnym, traktujemy wyrażenie relacyjne jako zawierające niejawny test typu w stosunku do typu stałej po prawej stronie wyrażenia. Jeśli programista zamierza obsługiwać więcej niż jeden typ danych wejściowych, należy to zrobić jawnie:

bool IsValidPercentage(object x) => x is
    >= 0 and <= 100 or    // integer tests
    >= 0F and <= 100F or  // float tests
    >= 0D and <= 100D;    // double tests

Wynik: relacyjna operacja zawiera niejawne sprawdzanie typu stałej po prawej stronie wyrażenia relacyjnego.

Przepływające informacje o typie od lewej do prawej strony and

Sugerowano, że podczas pisania kombinatora and informacje o typie najwyższego poziomu, poznane po lewej stronie, mogłyby przepływać na prawą stronę. Na przykład

bool isSmallByte(object o) => o is byte and < 100;

Wejściowy typ do drugiego wzorca jest zawężany przez wymagania zawężające typu po lewej stronie and. Zdefiniowalibyśmy semantykę zawężania typu dla wszystkich wzorców w następujący sposób. zawęziony typ wzorca P jest definiowany w następujący sposób:

  1. Jeśli P jest wzorcem typu, zawężony typ jest typem typu wzorca typu.
  2. Jeśli P jest wzorcem deklaracji, zawężony typ jest typem typu wzorca deklaracji.
  3. Jeśli P jest wzorcem rekursywnym, który daje jawny typ, zawęziony typ jest tym typem.
  4. Jeśli P jest dopasowywana za pośrednictwem regułITuple, zawężonego typu jest typem System.Runtime.CompilerServices.ITuple.
  5. Jeśli P jest stałym wzorcem, w którym stała nie jest stałą o wartości null i wyrażenie nie ma konwersji wyrażenia stałego do typu wejściowego , to typ zawężony jest typem stałej.
  6. Jeśli P jest wzorcem relacyjnym, w którym wyrażenie stałe nie ma konwersji wyrażenia stałego do typu wejściowego , zawężony typ jest typem tej stałej.
  7. Jeśli P jest wzorcem or, zawężony typ jest wspólnym typem dla zawężonego typu podwzorców, jeśli taki wspólny typ istnieje. W tym celu algorytm wspólnego typu uwzględnia tylko konwersje tożsamości, boksu i niejawnych odwołań oraz uwzględnia wszystkie podwzory sekwencji wzorców or (ignorując wzorce nawiasów).
  8. Jeśli P jest wzorcem and, to zawężony typ jest zawężonym typem dla właściwego wzorca. Ponadto zawężony typ wzorca po lewej stronie jest typem wejściowym wzorca po prawej stronie .
  9. W przeciwnym razie zawężony typ P jest typem wejściowym P.

Wynik: Powyższe zawężenie semantyki zostało zaimplementowane.

Definicje zmiennych i określone przypisanie

Dodanie wzorców or i not tworzy kilka interesujących nowych problemów dotyczących zmiennych wzorca i określonego przypisania. Ponieważ zmienne mogą być deklarowane zwykle co najwyżej raz, wydaje się, że każda zmienna wzorca zadeklarowana po jednej stronie wzorca or nie jest jednoznacznie przypisana, gdy wzorzec pasuje. Podobnie zmienna zadeklarowana wewnątrz wzorca not nie powinna być zdecydowanie przypisana, gdy wzorzec pasuje. Najprostszym sposobem rozwiązania tego problemu jest zakaz deklarowanie zmiennych wzorca w tych kontekstach. Jednak może to być zbyt restrykcyjne. Istnieją inne podejścia do rozważenia.

Jednym z scenariuszy, które warto wziąć pod uwagę, jest to

if (e is not int i) return;
M(i); // is i definitely assigned here?

Nie działa to dzisiaj, ponieważ dla wyrażenia-wzorca zmienne tego wzorca są uznawane za zdecydowanie przypisane jedynie tam, gdzie wyrażenie-wzorzec jest prawdziwe ("zdecydowanie przypisane, gdy spełnione").

Obsługa tej instrukcji byłaby prostsza (z perspektywy programisty) niż również dodanie obsługi negowanego warunku if. Nawet jeśli dodamy taką obsługę, programiści zastanawialiby się, dlaczego powyższy fragment kodu nie działa. Z drugiej strony, ten sam scenariusz w switch ma mniej sensu, ponieważ nie ma odpowiedniego punktu w programie, w którym zdecydowanie przypisane, gdy fałszywe byłoby znaczące. Czy zezwolimy na to w wyrażeniach z wzorcem -, ale nie w innych kontekstach, gdzie wzorce są dozwolone? Wydaje się to nieregularne.

Związane z tym jest problem określonego przypisania w disjunctive-pattern.

if (e is 0 or int i)
{
    M(i); // is i definitely assigned here?
}

Spodziewalibyśmy się, że i na pewno zostanie przypisana, gdy dane wejściowe nie są zerowe. Ale ponieważ nie wiemy, czy dane wejściowe są zerowe, czy nie wewnątrz bloku, i nie jest zdecydowanie przypisane. Co jednak zrobić, jeśli zezwolimy na deklarowanie i w różnych wzajemnie wykluczających się wzorcach?

if ((e1, e2) is (0, int i) or (int i, 0))
{
    M(i);
}

W tym miejscu zmienna i jest jednoznacznie przypisana wewnątrz bloku i przyjmuje wartość z innego elementu krotki, kiedy zostanie znaleziony element zerowy.

Zaleca się również zezwolenie na definiowanie zmiennych (pomnożenie) w każdym przypadku bloku przypadku:

    case (0, int x):
    case (int x, 0):
        Console.WriteLine(x);

Aby wykonać dowolną z tych czynności, musimy dokładnie zdefiniować, gdzie takie definicje są dozwolone i w jakich warunkach taka zmienna jest uważana za zdecydowanie przypisaną.

Jeśli zdecydujemy się odroczyć taką pracę na później (co doradzam), moglibyśmy powiedzieć w kontekście C# 9

  • pod not lub ornie można zadeklarować zmiennych wzorca.

Następnie mielibyśmy czas, aby zdobyć doświadczenie, które umożliwiłoby zrozumienie możliwej wartości poluzowania tego później.

Wynik: zmiennych wzorca nie można zadeklarować pod wzorcem not ani or.

Diagnostyka, subsumpcja i kompletność

Te nowe wzorce otwierają wiele nowych możliwości błędów programisty do diagnozowania. Musimy zdecydować, jakie rodzaje błędów będziemy diagnozować i jak to zrobić. Oto kilka przykładów:

case >= 0 and <= 100D:

Ten przypadek nigdy nie może być zgodny (ponieważ dane wejściowe nie mogą być zarówno int, jak i double). Mamy już błąd, gdy wykryjemy przypadek, który nigdy nie może być zgodny, ale jego sformułowanie ("Przypadek przełącznika został już obsłużony przez poprzedni przypadek" i "Wzorzec został już obsłużony przez poprzednie ramię wyrażenia przełącznika") może być mylący w nowych scenariuszach. Możemy zmodyfikować sformułowanie, aby po prostu powiedzieć, że wzorzec nigdy nie będzie pasować do danych wejściowych.

case 1 and 2:

Podobnie jest to błąd, ponieważ wartość nie może być 1 i 2.

case 1 or 2 or 3 or 1:

Ten przypadek jest możliwy do dopasowania, ale or 1 na końcu nie dodaje znaczenia do wzorca. Proponuję, abyśmy dążyli do wykrywania błędu zawsze, gdy jakiś spójnik lub rozłącznik wzorca złożonego nie definiuje zmiennej wzorca ani nie wpływa na zestaw dopasowanych wartości.

case < 2: break;
case 0 or 1 or 2 or 3 or 4 or 5: break;

Tutaj 0 or 1 or nie dodaje nic do drugiego przypadku, ponieważ te wartości byłyby obsłużone już przez pierwszy przypadek. To także zasługuje na uznanie za błąd.

byte b = ...;
int x = b switch { <100 => 0, 100 => 1, 101 => 2, >101 => 3 };

Wyrażenie przełącznika, takie jak to, należy uznać za wyczerpujące (obsługuje wszystkie możliwe wartości wejściowe).

W języku C# 8.0 wyrażenie przełącznika z danymi wejściowymi typu byte jest uznawane za wyczerpujące tylko wtedy, gdy zawiera ostateczne ramię pasujące do wszystkiego (wzorzec odrzucania lub wzorzec zmiennej ). Nawet wyrażenie przełącznika, które ma ramię dla każdej odrębnej wartości byte, nie jest uważane za wyczerpujące w języku C# 8. Aby poprawnie obsługiwać pełnię wzorców relacyjnych, będziemy musieli również obsłużyć ten przypadek. Technicznie rzecz biorąc, będzie to zmiana powodująca niezgodność, ale prawdopodobnie żaden użytkownik tego nie zauważy.