Wstęp
Wraz z wersją ósmą .NET, o której pisałem w tym artykule, dostaliśmy też wersję 12 języka C#. Dzisiaj opiszę Ci wszystkie nowości i zmiany w tej wersji. Lecimy.
Główne konstruktory – Primary constructors
Do tej pory mogliśmy ich używać tylko w recordach
. Od C#12 mamy je dostępne również w klasach i strukturach. Działają jednak troszkę inaczej. O co chodzi?
Spójrz na ten POPRAWNY w C#12 kod:
class Person(string name, int age)
{
public string Name { get; } = name;
public int Age { get; } = age;
}
Obok nazwy klasy znalazły się nawiasy z parametrami. To jest właśnie coś, co nazywamy primary constructor
. Zwróć uwagę, że te parametry: name
i age
są dostępne w całym ciele klasy. Można je odczytywać, zmieniać i robić wszystko to, co robiłbyś z prywatnymi polami. No właśnie… prywatnymi. Nie możesz się do nich dobrać z zewnątrz, dlatego też powyżej widzisz utworzone właściwości, które zwracają ich wartości.
Tak samo to działa w strukturach. I to jest ta różnica między klasą/strukturą, a rekordem:
record MyRecord(int X, int Y)
{
public bool IsNegative()
{
return X < 0 || Y < 0
}
}
Jeśli używasz primary constructor
w rekordach, kompilator automatycznie stworzy właściwości ( { get; init; }
) dla każdego takiego parametru. Dzięki czemu możesz się normalnie do nich odwoływać na rzecz obiektu.
Wróćmy jednak do naszej klasy:
class Person(string name, int age)
{
public string Name { get; } = name;
public int Age { get; } = age;
}
To, co tutaj widzisz to tak naprawdę cukier składniowy. Kompilator wygeneruje do tego kod, który będzie wyglądał mniej więcej tak:
class Person
{
private string __unspeakable_name;
private int __unspeakable_age;
public string Name => __unspeakable_name;
public Person(string name, int age)
{
__unspeakable_name = name;
__unspeakable_age = age;
}
}
Primary constructor z innymi konstruktorami
Jeśli chciałbyś mieć dodatkowe konstruktory, to musisz w nich wywołać ten primary constructor
. Robi się to za pomocą słówka this
:
class Person(string name, int age)
{
public string Name { get; } = name;
public Person(string name, DateTime birthday)
: this(name, DateTime.Now.Year - birthday.Year)
{
}
}
Dependency injection
Primary constructors
wspierają również mechanizm dependency injection
, co wydaje się całkiem interesującym rozwiązaniem. Prawdę mówiąc, to chyba jedyny powód, dla których chciałbym ich używać. No bo popatrz na standardowy kod:
class MyService
{
private readonly MyOtherService _service;
public MyService(MyOtherService service)
{
_service = service;
}
}
Używając primary constructors
możemy go skrócić do takiego zapisu:
class MyService(MyOtherService service)
{
}
Jest kompresja.
Operacje na parametrach
No dobra, a jak poradzić sobie z sytuacją, gdzie w konstruktorze musimy zrobić jakieś sprawdzenia, czy coś w ten deseń? Czyli po staremu:
class Person
{
public Person(string name)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Person must have a name");
}
}
Po prostu możemy użyć statycznej metody:
class Person(string name)
{
public string Name { get; } = ValidName(name) ? name : throw new ArgumentException("Person must have a name!");
private static bool ValidName(string name)
{
return !string.IsNullOrWhiteSpace(name);
}
}
Podczas tworzenia takiego obiektu, zostanie wywołana metoda ValidName
. No i jeśli podamy niepoprawną wartość, wywali się. Czyli kod poniżej zadziała tak, jak się tego spodziewamy:
var person = new Person(""); //wywali się
Niemniej jednak nie podoba mi się to. Chociaż to pewnie głównie kwestia gustu. Natomiast uważam, że to czyni kod brzydkim, brudnym, a im więcej takich rzeczy, tym ciężej będzie się go debugować.
Tworzenie kolekcji – Collection expressions
Bardzo miłe ułatwienie. Do tej pory, żeby stworzyć tablicę, trzeba było napisać:
int[] tab = new int[] { 1, 2, 3 };
Stary, fajny, klasyczny kod. Collection expressions
, które dostaliśmy w C#12 daje nam dużo prostszą i szybszą metodę:
int[] tab = [1, 2, 3];
Możemy to samo zrobić z listami, spanami i właściwie wszystkimi kolekcjami wspierającymi inicjalizatory:
List<int> list = [1, 2, 3];
IEnumerable<int> e = [1, 2, 3];
A teraz spójrz jeszcze na zaznaczoną powyżej linię. Widzisz, jak stworzyłem IEnumerable
?
Ale jak to? Przecież IEnumerable
to interfejs!
Azaliż. I tak naprawdę powstał obiekt klasy ReadOnlyArray<int>
.
W taki sposób można tworzyć też wszystkie rodzaje tablic – wielowymiarowe, poszarpane (jagged arrays) itd.
Jak dla mnie, bardzo fajne rozwiązanie. Bardzo ułatwi pracę i zdecydowanie będę z niego korzystał.
Inline arrays
UWAGA! To jest dość zaawansowany temat. Jeśli nie bawiłeś się pamięcią i nie używałeś bibliotek z innych języków (np. C++, Pascal), prawdopodobnie ten akapit niczego Ci nie da.
Inline array
to odpowiednio utworzona struktura. Jest odpowiednikiem fixed buffer
w bezpiecznym kodzie (safe code). Spójrz w jaki sposób możesz utworzyć taką strukturę:
[InlineArray(3)]
struct InlineBuffer
{
public int element0;
}
Oznacza to, że tworzymy 3 elementową tablicę intów. Jedna rzecz jest istotna:
InlineBuffer buff = new InlineBuffer();
Teraz zmienna buff
WSKAZUJE na pierwszy element tablicy – który jest jednocześnie polem element0
. Czyli nie jest to referencja do tablicy, tylko sama tablica.
To jest przydatne, gdy walczymy bardzo o szybkość wykonywania kodu. Kompilator gwarantuje, że buff
będzie po prostu ciągłą pamięcią o zadanej wielkości.
Ważne jest, że taka struktura nie może mieć żadnego layoutu i musi mieć jedno pole o typie, jakiego chcemy użyć w naszej tablicy.
Co istotne, to pole nie może być wskaźnikiem (w końcu to wskaźnik to niebezpieczny kontekst), ale może być każdym typem wartościowym i większością typów referencyjnych (w tym stringiem). A dobrać się możesz do konkretnych wartości tak samo jak w przypadku zwykłej tablicy – przez indekser. A także przez operator zakresu.
Domyślne parametry w wyrażeniach lambda
Nie ma się co rozpisywać. Po prostu w C#12 możemy używać domyślnych parametrów w wyrażeniach lambda. Wcześniej taki zapis w ogóle się nie kompilował. Obowiązują dokładnie te same reguły, co przy domyślnych parametrach metod:
var foo = (string s = "Siemma") => System.Console.WriteLine(s);
foo(); //wyświetli -> Siema
foo("Hej"); //wyświetli -> Hej
Modyfikator ref readonly
Do tej pory mieliśmy takie modyfikatory, którymi mogliśmy oznaczać parametry metody:
- in
- out
- ref
W C#12 doszedł nowy: ref readonly
.
W sumie, ref readonly
robi dokładnie to samo, co in
. Przyjrzałem się nawet kodowi generowanemu przez IL i te poniższe fragmenty C# tworzą dokładnie ten sam kod IL:
static void Main(string[] args)
{
int val = 10;
Show(val); //<-- pierwszy przypadek
Show2(ref val); //<-- drugi przypadek
Show2(in val); //<-- trzeci przypadek
}
public static void Show(in int value)
{
System.Console.WriteLine(value);
}
public static void Show2(ref readonly int value)
{
System.Console.WriteLine(value);
}
A kod IL też jest dość prosty, bo najpierw odkłada na stos ADRES zmiennej val
, następnie wywołuje metodę, która ten adres ze stosu pobiera.
Jednak pewne, nieznaczne różnice są:
- metoda przyjmująca parametr
in
nie potrzebuje, żeby kod wywołujący dawał o tym znać. Przyref readonly
bez jawnego określenia dostaniesz warning. Czyli:
//tu wszystko jest ok
Show(val);
public static void Show(in int value)
{
System.Console.WriteLine(value);
}
//tutaj dostaniesz warning, bo powinieneś wywołać metodę z modyfikatorem ref lub in (nie ma żadnego faktycznego znaczenia dla kodu):
Show(val); //<-- dostaniesz warning
//Show(ref val); //<-- bez warninga
//Show(in val); <-- bez warninga
public static void Show(ref readonly int value)
{
System.Console.WriteLine(value);
}
Zaznaczam – nie ma znaczenia, czy wywołasz metodę (ref readonly
) z modyfikatorem ref
, czy in
. Kod w IL zostanie utworzony dokładnie ten sam.
- jeśli przekażesz wartość (lub wyrażenie) do metody z modyfikatorem
in
, wszystko będzie ok; jeśli zrobisz to samo z metodą z modyfikatoremref readonly
– dostaniesz warning:
//tu wszystko jest ok
Show(10);
public static void Show(in int value)
{
System.Console.WriteLine(value);
}
//tutaj dostaniesz warning
Show(10); //<-- dostaniesz warning
public static void Show(ref readonly int value)
{
System.Console.WriteLine(value);
}
Jeśli jesteś ciekawy, co się wydarzy tutaj, to już mówię. Kompilator tak jakby stworzy za Ciebie zmienną, której przypisze wartość 10. Następnie adres tej zmiennej przekaże do metody.
Więc po co to ref readonly
? Nie wiem. W specyfikacji czytam coś takiego:
W C# 7.2 wprowadzono parametry „in” jako sposób przekazywania referencji tylko do odczytu (w C++ nazywałoby się to stałą referencją – przyp. Adama). Parametry „in” dopuszczają zarówno lvalues jak i rvalues i można ich używać bez żadnej adnotacji podczas wywoływania.
Jednakże interfejsy API, które przechwytują lub zwracają referencje ze swoich parametrów chciałyby uniemożliwić rvalue a także wymusić pewne wskazanie w miejscu wywołania, że przechwytywana jest referencja.
Parametry ref readonly są idealne w takich przypadkach, ponieważ ostrzegają, jeśli zostaną użyte z rvalue lub bez żadnej adnotacji.
Także ma to na celu chyba tylko indykację, że metoda przyjmuje referencję.
Atrybut Experimental
C#12 daje nam nowy atrybut. System.Diagnostics.CodeAnalysis.ExperimentalAttribute
. Możemy nim oznaczyć chyba wszystko. Od klasy, czy też enuma do pola, właściwości, czy zdarzenia.
Jeśli w kodzie użyjemy czegoś, co jest oznaczone atrybutem Experimental
, kompilator wypluje ostrzeżenie, które zachowa się jak błąd. Trzeba jawnie oznaczyć to stłumić (suppress) to ostrzeżenie, żeby taki kod się zbudował.
Jeśli jednak wywołanie eksperymentalnego elementu będzie w innym eksperymentalnym elemencie, wtedy takie ostrzeżenie się nie pojawi. Czyli np.:
internal class MyClass
{
public static void Run()
{
Foo();
}
public static void Foo()
{
}
[Experimental("")]
public static void Bar()
{
}
}
Powyższy kod skompiluje się bez problemu. Poniższy też
public static void Run()
{
//Foo(); <-- wykomentowane wywołanie, czyli nie używamy metod eksperymentalnych
}
[Experimental("")]
public static void Foo()
{
Bar();
}
[Experimental("")]
public static void Bar()
{
}
Ale poniższy da już błąd:
public static void Run()
{
Foo(); //<-- użycie eksperymentalnej metody
}
[Experimental("")]
public static void Foo()
{
Bar();
}
Błąd mówi: Error CS9204 'Foo()' is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
Więc, jeśli chcemy jednak używać metody eksperymentalnej, musimy to ostrzeżenie będące błędem wyłączyć:
public static void Run()
{
#pragma warning disable CS9204
Foo();
#pragma warning restore CS9204
}
[Experimental("")]
public static void Foo()
{
Bar();
}
Można też zmniejszyć poziom tego ostrzeżenia – dokładnie tak samo jak innych. Możemy też to zrobić oczywiście globalnie, ale tego bardzo nie polecam.
Po co ten atrybut?
Raczej dla twórców bibliotek. Jeśli wprowadzają jakieś działanie, które w przyszłości może się mocno zmienić lub w ogóle zostać wywalone, wtedy oznaczenie tego jako Experimental
jest dobrym pomysłem. A czy używanie takiego eksperymentalnego kodu jest dobrym pomysłem? Na produkcji raczej nie. W swoich wewnętrznych testach można się pobawić.
Wyłączanie ostrzeżeń o konkretnych funkcjonalnościach
Atrybut Experimental
ma ciekawą właściwość. Można mu przekazać coś w rodzaju Id danej funkcjonalności (DiagnosticId), a potem tym Id posługiwać się podczas wyłączania ostrzeżenia. Np.:
public static void Run()
{
#pragma warning disable DoingFoo
Foo();
#pragma warning restore DoingFoo
}
[Experimental("DoingFoo")]
public static void Foo()
{
Bar();
}
Co więcej, możesz przekazać więcej informacji w komunikacie błędu – a konkretnie adres strony, na której jest opisana ta funkcjonalność lub powód dlaczego klienci nie powinni tego używać:
[Experimental("DoingFoo", UrlFormat = "https://example.com/{0}")]
public static void Foo()
{
Bar();
}
Wtedy błąd będzie wyglądał tak:
Error DoingFoo 'Foo()' is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. (https://example.com/DoingFoo)
Interceptory
W chwili pisania tego artykułu (styczeń 2024) interceptory są eksperymentalne, więc póki co, nie opisuję ich. Ale będę trzymał rękę na pulsie i jeśli wejdą do użycia, na pewno o nich napiszę. Żeby tego nie przegapić, koniecznie zapisz się na newsletter 🙂
Dzięki za przeczytanie tego artykułu. Widać coraz bardziej, że C# idzie w stronę minimalizacji pisanego kodu, co jest z jednej strony fajnym rozwiązaniem. Chociaż może być cięższe do zrozumienia dla początkujących programistów. Niemniej jednak uważam, że to dobry krok naprzód. I choć w tej wersji może nie było jakiś super wielkich zmian, to jednak miło że MS słucha community.
Jeśli czegoś nie rozumiesz lub zauważyłeś w tekście jakiś błąd, daj znać w komentarzu 🙂