Nowości w C# 7.0

Trochę spóźniony wpis o nowościach w C# 7.0. Już od prawie roku jest możliwość używania wersji C# 7.0 a C# 8.0 już się dobrze trzyma. Jednak warto przypomnieć  sobie o  zmianach aby je sobie na nowo utrwalić. Widzę wyraźnie, że tak szybkie ewoluowanie języka sprawia, że nawyki wygrywają i nie stosujemy nowego podejścia. Zapraszam na analizę nowości i krótkie komentarze po roku użytkowania.

Skrót zmian

  1. Zmienne OUT w jednej linijce
  2. Tuple
  3. Dekonstrukcje
  4. Discards
  5. Pattern Matching
  6. Zwracanie ref-a
  7. Lokalne funkcje
  8. Expression-bodied
  9. Wyrażenia throw
  10. Literały numeryczne

Zmienne OUT w jednej linijce

Konkretna i dobra zmiana. Definiowanie zmiennych out w jednej linijce. Podajemy nazwę i typ zmiennej w jednej linijce. Ułatwia to czytanie oraz poprawia wygląd kodu.

private void OutVariable()
{
// old times

int numericResult;

if (int.TryParse("83", out numericResult))
{
Console.WriteLine(numericResult);
}

// C#7

if (int.TryParse("83", out int numericResult2))
{
Console.WriteLine(numericResult2);
}
}

Linijka 14 pokazuje zmianę podejścia. Najpierw słowo kluczowe out potem typ (może być też słowo kluczowe var) i nazwa zmiennej. Zasięg zmiennej jest taki jak zwykłej zmiennej w metodzie. Oczywiście taką konstrukcję możemy stosować w dowolnych metodach, gdzie występuje słowo kluczowe out

Tuple

Konstrukcja Tupli zawsze była kontrowersyjna, ale czasem jest to dość wygodny typ aby zwracać więcej niż jedną zmienną z metody bez użycia dodatkowej klasy modelu.

Poniżej przykład jak można było dotychczas używać tupli:

private void Tuple()
{
// old times

var tuple = GetTuple();

var year = tuple.Item1;
var name = tuple.Item2;
}

private Tuple<int, string> GetTuple()
=> new Tuple<int, string>(1983, "Year");

Jak widzimy mocne ograniczania co do nazw zwracanych parametrów.

Używając C# 7.0 mamy o wiele większą możliwość używania Tupli w sposób, który jest czytelny i dość prosty

Po pierwsze nowy sposób tworzenia Tupli:

private void NewTuple()
{
var tuple = (1893, "Year");

var year = tuple.Item1;
var name = tuple.Item2;
}

Nie trzeba już wprost określać jaki typu i wartości. Można skrócić implementacje do tej pokazanej w linijce 3. Odwołanie do pól może pozostać takie jak poprzednio po polach Item1, Item2. O typy dba kompilator i sam się domyśla jaki typ przypisać.

Teraz jednak najważniejsza zmiana, która ułatwia czytanie kodu. Dekonstrukcja Tupli czyli dopasowywanie wartości od nazw zmiennych. Przykład poniżej:


private void NewTuple()
{
var tuple = (1893, "Year");

var year = tuple.Item1;
var name = tuple.Item2;

(int year2, string name2) = tuple;

WriteLine(year2); // 1983
WriteLine(name2); // Year
}

Dekonstrukcja w tym przypadku dopasowuje typ Tupli(tuple) do parametrów podanych w nawiasach okrągłych. Liczy się kolejność parametrów. Czyli pierwszy parametr Item1  jest dopasowywany do pierwszej zmiennej, która jest w nawiasach(year2). Typy może się domyślić kompilator więc można używać słowa var.

Taka konstrukcja rozwiązuje największy mankament Tupli czyli ich nie czytelność zwracanych parametrów.

Stosuje od początku i działa to wspaniale. Trzeba się trochę przyzwyczaić do konstrukcji, podczas czytania.

Dodatkowo sposobów na tworzenie Tupli jest więcej w zależności od potrzeb. Poniżej przykład:

var model = (Year: 1983, Name: "Year");

WriteLine(model.Name); // 1983
WriteLine(model.Year); // Year

Tworzymy tuple i po lewej stronie wyrażenia możemy podać nazwę zmiennej i wartość. Nazwa „dwukropek” i wartość.

Używanie Tupli jak pisze dokumentacja zalecane jest do metod private i internal. To ma sens, bo w takim zakresie, praca z takimi obiektami ułatwi i zniweluje chaos dodatkowych klas i struktur do przekazywania parametrów a inne metody typu public kultura programistyczna zaleca aby była w modelu w klasie czy strukturze. Zalecam trzymać się takiej konwencji.

Dekonstrukcja

Opisana powyżej dekonstrukcja Tupli do obiektów jest również dostępna do samodzielnego użytku. Możemy stworzyć implementację metody Deconstruct  w dowolnym typie w .NET. Dzięki temu możemy tworzyć bardziej złożone konstrukcje. Poniżej przykład:

public class Point
{
public Point(double x, double y)
{
this.X = x;
this.Y = y;
}

public double X { get; }
public double Y { get; }

public void Deconstruct(out double x, out double y)
{
x = this.X;
y = this.Y;
}
}
var p = new Point(3.14, 2.71);
(double X, double Y) = p;

Nie korzystałem z tego jeszcze w praktycznym projekcie ale jestem pewny, że może się to czasem przydać do poprawy czytelności kodu pod warunkiem, że czytający będzie tego świadomy takiej konstrukcji. Cały myk tego rozwiązania polega na tym, że możemy szybko przypisać do zmiennych wartości jakiegoś typu w jednej linijce.

Discards

Powiem tak jak się nie powinno pisać na blogu. Ta funkcjonalność to „olewanie parametrów” :  )

Przy dekonstrukcji musimy podać nazwy parametrów, gdy parametrów będzie bardzo dużo lub jakiś parametr nie będzie nam potrzeby to możemy go pominąć. Aby to zrobić zamiast nazwy i typu wystarczy wpisać podkreślnik (_). Program się skompiluje i parametr nie będzie widoczny.

private void Discards()
{
var tuple = (2, "Day");

(var day, _) = tuple;
}

Pattern Matching

O tej funkcjonalności można dużo napisać. Nie będe tego robił, bo nie za bardzo jest potrzeba. Skupie się na śmietance tego rozwiazania.

Pattern Matching to nic innego jak możliwość sprawdzenia czy typ zmiennej pasuje do tego typu, o którego pytamy.

Pattern Matching możemy używać w 2 miejscach:

  • konstrukcji swich
  • konstrukcji  is
IS

konstrukcja is w swojej podstawowej wersji sprawdzała czy dany obiekt  jest typem o, który pytamy. Zwracała true/false w zależności od wyniku, potem mogliśmy użyć słowa as aby przypisać dany typ do zmiennej.

W nowej wersji sprawdza czy dany typ jest takim typem o, który pytamy i od razu przypisuje wartość tego typu do zmiennej.

private void PatternMatchingIs()
{
var type = new Point();

if (type is var point)
{
WriteLine(point.X);
WriteLine(point.Y);
}
}

Jak widzimy w sprawdzeniu warunku od razu robimy przypisanie do zmiennej.  Tutaj nie ma wielkiej poprawy czytelności ale przyspiesza to pisanie kodu.

Szału nie ma…

Switch

Tutaj robi się ciekawie. Do momentu C# 7 nie było możliwość w poleceniu case, sprawdzić jakiego typu jest dany obiekt. Mogliśmy tylko sprawdzać wartości zmiennych. W nowej wersji w poleceniu case możemy bezpośrednio używać przypisania do typów. Wygląda to następująco:

public int DiceSum(IEnumerable<object> values)
{
var sum = 0;
foreach (var item in values)
{
switch (item)
{
case 1:
sum += 1;
break;
case int val when val == 5:
sum += 5;
break;
case int val:
sum += val;
break;
case IEnumerable<object> subList:
sum += DiceSum(subList);
break;
}
}
return sum;
}

Jak widzimy pierwszy case sprawdza czy item jest typu int i jeśli jest to czy jest większy od 5 jeśli tak to warunek jest spełniony.

Kolejny case sprawdza  czy item jest typu int, jeśli tak to wykonuje case-a. Kolejny sprawdza czy item jest typu IEnumerable<object> .

Jak widać sprawdzanie typów i klauzula when jest bardzo ciekawa i do tego może tchnąć nowe życie w instrukcję switch, której nie wszyscy chętnie używają.

Jedna uwaga, do kolejności sprawdzania warunków, te najbardziej ogólne musza być najniżej aby były sprawdzane jak ostatnie. Więcej szczegółów tutaj: https://docs.microsoft.com/pl-pl/dotnet/csharp/pattern-matching

Zwracanie ref-a

To jest dość niskopoziomowa jak na c# funkcja pozwalająca używać słowa kluczowego ref w instrukcji return jak i w przed typem zwracanym w metodzie. Po co to się wykorzystuje? Odpowiedź jest prosta aby poprawić wydajność rozwiązań. Poniżej przykładowy metoda, która przyjmuje tablicę int i oraz index i zwraca wartość spod tego indeksu.

private void RefReturn()
{
var array = new int[4];

array[0] = 1;
array[1] = 2;
array[2] = 3;
array[3] = 4;

var item2 = GetItem(array, 1);

item2 = 100;

WriteLine(array[1]);  // 2
}

private int GetItem(int[] array, int v)
{
return array[v];
}

Problemem jest to, że metoda zwraca konkretną wartość i gdy ją modyfikujemy to modyfikujemy wartość tej zmiennej a nie wartość tego co znajduje się pod konkretnym indeksem tablicy. W przykładzie widać, że zmiana elementu o indeksie 1 nie odnosi skutku.

Po modyfikacji kodu zgodnie z tym co umożliwia C# 7.0 możliwe staje się operowanie na referencjach do obiektu o indeksie 1. Aby to umożliwić należy dodać słowo ref w niestety 4 miejscach.

private void RefReturn()
{
var array = new int[4];

array[0] = 1;
array[1] = 2;
array[2] = 3;
array[3] = 4;

ref var item2 = ref GetItem(array, 1);

item2 = 100;

WriteLine(array[1]);  // 100
}

private ref int GetItem(int[] array, int v)
{
return ref array[v];
}

Dość sporo tych miejsc, czyli: w definicji metody przed zwracanym typem, po słowie return w metodzie oraz przed wywołaniem metody i przed deklaracją zmiennej do której przypisujemy referencje.

Kiedy to stosować, no zawsze wtedy gdy jest to potrzebne. Dzięki tej możliwość kod może stać się czytelniejszy nie tracąc na wydajności.

Lokalne funkcje

Bardzo prosty temat. Od wersji C# 7.0 można stosować lokalne funkcje, czyli w danej metodzie, może dopisać kolejną metodę. Wygląda to w następujący sposób:

private void LocalFunction()
{
var sum = 0;

foreach (var item in Enumerable.Range(0,2))
{
Localmethod(item);
}

void LocalMethod(int item)
{
sum+=item;
}
}

Metoda LocalMethod() jest widziana tylko z poziomu LocalFunction(). Takich metod w metodach nie można przeciążać, mogą być zadeklarowane po słownie return w metodach.

Wg dokumentacji takie zastosowanie jest najbardziej optymalne dla funkcji w iteratorach oraz dla metod asynchronicznych. Dlaczego?, bo gdy metoda zwraca powiedzmy IEnumerable to metoda ta zostanie wykonana tylko wtedy gdy zostanie iterowana. Jeśli w metodzie, są sprawdzane jakiś warunki to do momentu iterowania nie będą one wykonane. Poniżej prosty przykład:

private void LocalFunction()
{
var resultSet = GetList(2);

// no exception because no iterate

foreach (var item in resultSet)
{
// throw exception
}
}

private IEnumerable&lt;int&gt; GetList(int i)
{
if (i &gt; 3)
{
throw new Exception();
}

foreach (var item in Enumerable.Range(0, 2))
{
yield return item;
}
}

Aby zapobiec temu problemowi można użyć właśnie lokalnej funkcji w taki sposób:

private IEnumerable&lt;int&gt; GetList(int i)
{
if (i &lt; 3)
{
throw new Exception();
}

return returnList();

IEnumerable&lt;int&gt; returnList()
{
foreach (var item in Enumerable.Range(0, 2))
{
yield return item;
}
}
}

Jest to dość sprytny sposób uniknięcia problemu przedstawionego powyżej. Może to też zrobić inaczej ale nich będzie, że to jest najbardziej eleganckie. Podobny problem występuje w metodach asynchoronicznych, że zwracamy Task-a.

Expression-bodied

W C# 6.0 pojawiły się możliwość skrócenia metod do samych wyrażeń (czyli 1 linijkowych definicji metod, używając do tego składni ala lamdba.

private int SomeFunction =&gt; 1983+5+2;

W C# 7.0 zostało to rozszerzone o konstruktory, destruktory

public int Prop { get; set; }

public C72Features(int d) =&gt; this.Prop = d;

oraz o pola i metody get i set

private int _prop;

public int Prop
{
get =&gt; _prop;
set =&gt; value = _prop;
}

Czy to jest takie ważne. No cóż największą wartością dodaną tego pomysłu jest to, że została ona wykonana przez społeczność. A to wielki krok.

Wyrażenia throw

W C# 7.0 dodano możliwość rzucania wyjątków nie tylko w kodzie ale też w wyrażeniach takich jak operatory warunkowe, poniżej przykład.

private ConfigResource loadedConfig = LoadConfigResourceOrDefault() ??
throw new InvalidOperationException("Could not load config");

Funkcja jak najbardziej potrzeba i trochę porządkuje kod. Polecam ; )

Literały numeryczne

Prosta i dobra zmiana. Od teraz możemy sformatować liczby w sposób bardziej czytelny dla człowieka. Do rozdzielenia liczb i reprezentacji binarnych należy stosować  podkreślnik (_). Jest to bardzo pożyteczne, bo jak widać w przykładzie, 100 miliardów rzuca się od razu w oczy.

private void NumericLiterals()
{
const long BillionsAndBillions = 100_000_000_000;

const double AvogadroConstant = 6.022_140_857_747_474e23;

decimal GoldenRatio = 1.618_033_988_749_894_848_204_586_834_365_638_117_720_309_179M;

const int Sixteen =   0b0001_0000;

const int ThirtyTwo = 0b0010_0000;

const int SixtyFour = 0b0100_0000;

const int OneHundredTwentyEight = 0b1000_0000;

const int One =  0b0001;

const int Two =  0b0010;

const int Four = 0b0100;

const int Eight = 0b1000;
}

 

Podsumowanie

Zmian w C# 7.0 jest bardzo dużo, są one mniej lub bardziej kontrowersyjne, jednak przy zachowaniu zdrowego rozsądku wydają się ciągle zbliżać C# do języka funkcyjnego. Warto też zwrócić uwagę, że zmiany dotyczą nie tylko samej wydajności oraz dodawania nowych możliwości ale też dotykają poprawiania komfortu pracy na co dzień. Oby tak dalej.

Dokładnie o wszystkich zmianach można poczytać tutaj: https://docs.microsoft.com/pl-pl/dotnet/csharp/whats-new/csharp-7#numeric-literal-syntax-improvements