.NET ecosystem and C# best practices
Лекція про екосистему .NET та кращі практики написання C#-коду почнеться за 5..4..3... Але спочатку 🥁 кілька дісклеймерів про саму ж лекцію:
- багато з чого, про що йдеться у лекції, ви вже мабуть чули (або ні);
- деякі теми та терміни висвітлюються поверхнево, не вдаючись у деталі;
- лекція може здатися затягнутою, але believe me — це швидше, ніж прошуршати документацію MSDN-у в пошуках потрібної інфи 😝.
Вам також знадобиться стартовий пакет розробника .NET: .NET, Git, Visual Studio або Rider.
Огляд .NET платформи
Складність: Easy peasy lemon squeezy. Мета: Почитати/послухати про платформу .NET в загальному (довго не буде).Зараз компанія Microsoft вкладає досить багато ресурсів у розвиток .NET та їхньої хмарної платформи — Azure (Ажур, Ежур, Ейжа) та найбільше контрибьютить в open source. Інфраструктура .NET стрімко росте та розвивається, постійно створюютсья нові інструменти для роботи з нею, додаються інтеграції з іншими сервісами.
Шляхи створення .NET-додатків:
- .NET Framework — розробка під Windows настільних додатків на Windows Forms, WPF, веб-серверів на ASP.NET та WCF
- .NET Core — розробка кросплатформних веб-додатків за допомогою ASP.NET Core, створення гібридних додатків з допомогою Universal Windows Platform, яка дозволяє запускати програму написану на цій технології на Windows-машині, Xbox, Hololens
- Xamarin — платформа для створення мобільних додатків для iOS i Android, використовуючи C#, XML та XAML
Код, який написаний під спеціальний фреймворк, як WPF, ASP.NET Core чи Android, не можна перевикористати на іншій платформі, тому що він заточений для роботи з так званими platform-specific API, який відрізняється у них всіх. Щоб можна було повторно використовувати код бізнес-логіки, хелпер-методів, моделей, класів і так далі було створено .NET Standart, який надає набір доступних АPI, які однаково працюють у всіх десктопних програмах, веб-серверах, мобільних додатках, іграх та хмарних службах незалежно від операційної системи і платформи.
pЗ листопада 2021 року випущено .NET 6. Що стало серйозним вдосконаленням системи розробки в цілому. Головним нововведенням стала підтримка Linux, macOS, iOS, Android, tvOS, watchOS і WebAssembly. В результаті стало можливим створювати додатки для різних платформ на загальній базі коду з однаковим процесом збирання, незалежно від типу додатка. Тож тепер ви можете розробляти за допомогою Visual Studio, Visual Studio для Mac, Visual Studio Code або будь-якої іншої IDE за допомогою dotnet CLI
Кожному програмісту рано чи пізно гарантовано потрібно імплементовувати функціонал, який частково або повністю хтось раніше вже створив і навіть опублікував (як правило у вигляді DLL бібліотеки). Розробники називають такі модулі "пакетами", в які складено скомпільований код, додаткові файли-ассети та маніфест, що пояснює мету та спосіб використання пакету. У більшості мов програмування є власні платформи для обміну такими корисними модулями, у .NET це NuGet, пітримуваний Microsoft-ом. Розробники, які створили крутий інструмент чи, наприклад, бібліотеку для роботи з файловою системою, мають можливість опублікувати свою роботу як NuGet-пакет в вигляді zip-файлу з розширенням .nupkg. Ви можете шукати та скачувати модулі, які пришвидшать розробку вашого додатку, з центрального репозиторію NuGet Gallery ― він налічує вже близько 100000 унікальних пакетів і там може знайтись щось корисне.
Сьогодні у .NET-світі найпоширеніші мови програмування — це C#, F# та Visual Basic. У кожної є свій компілятор, який перетворює код написаний на цій мові у Intermediate Language Code (IL), який представляє з себе набір інструкцій для віртуальної машини .NET — CLR (Common Language Runtime).
Основні етапи виконання програми .NET:
- Звичайний C# код
public void SumTwoNumbers() { int firstNumber = 10; var secondNumber = 200; Console.WriteLine(firstNumber + secondNumber); }
C# код, скомпільований в IL.method public hidebysig instance void SumTwoNumbers() cil managed { // Method begins at RVA 0x2070 // Code size 18 (0x12) .maxstack 2.locals init([0] int32 firstNumber, [1] int32 secondNumber) IL_0000: ldc.i4.s 10 IL_0002: stloc.0 IL_0003: ldc.i4 200 IL_0008: stloc.1 IL_0009: ldloc.0 IL_000a: ldloc.1 IL_000b: add IL_000c: call void[mscorlib] System.Console::WriteLine(int32) IL_0011: ret }
- Тоді, коли прийде час для виконання частини нашого коду в програмі, CLR за допомогою JIT (Just in Time) компілятора перетворить код IL на машинний код.
Результатом білду .NET-програми є файл з розширенням .exe (Executable) або .dll (Dynamic Link Library).
Важливо зазначити, що при перетворенні IL в машинний код буде перетворена тільки та частина коду, яка має виконатись в теперішній момент часу.
На найвищому рівні у С# є 2 типи даних — це значимі типи (value types) і ссилочні типи (reference types). Важливо розуміти відмінності між ними:
Значимі типи:- Цiлочисленні типи
- Типи з плаваючою крапкою
- decimal
- bool
- enum-и
- структури
Ссилочні типи:- тип object
- string
- класи
- інтерфейси
- делегати
Значимі типи зберігаються у стеку, ссилочні на кучі. Value types передаються по значенню, тобто копіюються, reference types передаються за посиланням.
У .NET пам'ять ділиться на два типи: стек і кучу. Стек являє собою структуру даних, яка росте знизу вгору: кожен новий елемент розміщується поверх попереднього. У стеку зберігаються значимі типи та посилання на ссилочні типи, які, у свою чергу розміщуються на кучі.
Кучу можна уявити як невпорядкований набір різнорідних об'єктів. При створенні об'єкту ссилочного типу в стек додається посилання на адресу цього об'єкту у кучі. Коли об'єкт cсилочного типу перестає використовуватися, то посилання з стеку видаляється, і пам'ять звільняється.
У .NET-і очищення пам'яті відбувається автоматично. За це відповідає Garbage Collector (по нашому — сміттєзборщик). Коли він бачить, що на об'єкт в кучі більше немає посилань, він видаляє цей об'єкт та очищує пам'ять.
Важливий момент у тому, як змінні значимих та ссилочних типів передаються у метод.
Value Type
static void Main(string[] args) { int c = 20; MethodValue(c); Console.WriteLine(c); // 20 } // pass copy of the value static void MethodValue(int variableCopy) { variableCopy = 1; }
Reference Type
class City { public int code; public City(int code) { this.code = code; } } static void Main(string[] args) { City city = new City(12); MethodReference(city); Console.WriteLine(city.code); // 0 } // pass the reference to the object on heap static void MethodReference(City city) { city.code = 0; }
Ref
static void Main(string[] args) { int d = 30; MethodValueRef(ref d); Console.WriteLine(d); // 2 } // pass value by reference static void MethodValueRef(ref int variable) { variable = 2; }
Out
static void Main(string[] args) { int e; MethodValueOut(out e); Console.WriteLine(e); // 3 } // pass value by reference static void MethodValueOut(out int variable) { variable = 3; }
Операція упаковки — boxing — це виділення пам'яті на кучі під об'єкт значимого типу — value type, і присвоєння ссилки на цю ділянку пам'яті змінній в стеці. Розпакування — unboxing, навпаки, виділяє пам'ять в стеку під об'єкт, отриманий з кучі по ссилці.
int i = 123; // a value type object o = i; // boxing int j = (int)o; // unboxing
Найважливіші теми C#
Складність: Ну таке собі. Мета: Зрозуміти SDK.Структури по вигляду дуже схожі на Класи, але існує принципова відмінність, яка згадувалась раніше. Клас — це reference type і передається по ссилці, а структура — value type і передається за значенням — тобто копіюється.
Структури краще використовувати для невеликих класів, маленьких структур даних та неважких об'єктів. Класи ж можна використовувати у всіх випадках, де вам незручно використовувати структуру. Вони чудово підходять для того щоб бути частиною ієрархії сутностей, мати внутрішній стан та містити в собі велику кількість бізнес логіки.
Класи та структури можуть мати статичні поля, методи та властивості. Якщо член статичний, то він відноситься до усього класу чи структури і для звернення до нього не потрібно створювати екземпляр.
class Wallet { public static int Bonus = 100; public int balance; public Wallet(int bal) { balance = bal; } public int GetBalance() { return balance + Bonus; } public static int GetPureBalance() { // Error: Cannot access non-static field in static context return balance; } } static void Main(string[] args) { Console.WriteLine(Wallet.Bonus); // 100 Wallet.Bonus += 200; var wallet1 = new Wallet(150); Console.WriteLine(wallet1.GetBalance()); // 450 }
На прикладі показано, що статичне поле є спільним для усіх об'єктів класу і може використовуватись у нестатичних методах. В той же час у статичних методах у нас немає доступу до нестатичних членів класу.
Використовуючи ключове слово
params
ми можемо "сказати" що наш метод приймає невизначену кількість параметрів — це може бути нуль або більше, будь-яка кількість.public class Program { public static void UseParams(string str, params int[] list) { Console.Write(str); for (int i = 0; i < list.Length; i++) { Console.Write(list[i] + " "); } } static void Main() { // You can send a comma-separated list of arguments // of the specified type. UseParams("params: ", 1, 2, 3, 4); // params: // 1 2 3 4 } }
Коли метод має змінну кількість параметрів, ми передаємо аргументи йому просто перераховуючи їх через кому, як показано на прикладі. Варто зазначити, що аргумент
params
повинен бути вказаним останнім, після переліку усіх строго визначених аргументів методу.У C# абстракція використовується для приховання деталей реалізації. Це означає, що ми зосереджуємось на тому, що об'єкт може робити, а не на тому, як він це робить. Це часто використовується у написанні великих і складних програм. Основні інструменти для цього — абстрактні класи та інтерфейси.
У абстрактном класі ми можемо створити функціонал, який реалізується у похідному від нього класі. Зi свого боку інтерфейс дозволяє визначити функціональні можливості або функції, але не може їх реалізувати.
Клас імплементує інтерфейс та обов'язково реалізує ці методи. Розглянемо кілька ключових відмінностей між ними:
- Інтерфейс не може мати модифікаторів доступу до членів — все що є в інтерфейсі по дефолту є публічним. У абстрактного класу все залишається як і у звичайного класу.
interface TestInterface { // Causes syntax error protected void GetMethod(); public string PublicProp { get; set; } }
abstract class TestAbstractClass { public abstract string GetStuff(); public abstract void DoSmth(); }
- В Інтерфейсі ми можемо лише описати сигнатуру методу без його імплементації. А у абстрактному класі можуть знаходитися як абстрактні методи та властивості, так і не абстрактні — з повною або частковою реалізацією.
interface TestInterface { // Only signature void GetMethod(); }
abstract class TestAbstractClass { // Complete method implementation public string GetStuff() { Console.WriteLine("Stuff"); return "Stuff"; } }
- Ми не можемо оголосити конструктор у тілі інтерфейсу ні з модифікатором доступу, ні без нього. У абстрактному класі ми можемо оголошувати конструктори з тими самими правилами, що і у звичайних класах. Він здебільшого використовується для виклику у конструкторі похідного класу, щоб не дублювати код ініціалізації полів чи властивостей абстрактного класу.
interface TestInterface { // Interfaces cannot contain instance constructors TestInterface() { } public TestInterface(int s) { } }
abstract class TestAbstractClass { public int a; public string b; // Everything is OK - we can do that public TestAbstractClass(int a, string b) { this.a = a; this.b = b; } }
- Ми не можемо явно створити інстанс інтерфейсу чи абстрактного класу викликавши конструкор. Хоча нагадаю, що у абстрактного класу він може бути.
static void Main(string[] args) { // Causes syntax error TestInterface testInterface = TestInterface(); // Causes syntax error as well TestAbstractClass abstractClass = TestAbstractClass(); }
- Абстрактний клас може містити поля і властивості, інтерфейс — лише властивості.
interface TestInterface { // Causes syntax error int field; const string name; }
abstract class TestAbstractClass { // public static int n = 1; protected const int m = 3; private int k = 3; }
What, why and where?
Інтерфейс ми використовуємо щоб описати API для кількох класів, які, швидше за все, будуть імплементувати більше одного інтерфейсу. Треба пам'ятати, що члени інтерфейсу не можуть бути статичними. С#, на відміну від С++, не підтримує множинне наслідування, тому щоб його реалізувати, ми використовуємо інтерфейси.
Абстрактний клас використовується, якщо ми хочемо його включити в ієрархію успадкувань і створити функціонал з повною або частковою реалізацією, яку клас-наслідник може імплементувати або перевизначити. Абстрактний клас надає можливість зберігати стан класу в цілому, а не окремого його об'єкту.
Інтерфейс в основному використовується тоді, коли ми хочемо просто описати API використання класів, які будуть імплементувати цей інтерфейс ― задати поведінку.
- Інтерфейс не може мати модифікаторів доступу до членів — все що є в інтерфейсі по дефолту є публічним. У абстрактного класу все залишається як і у звичайного класу.
IDisposable
оголошує єдиний методDispose
, в якому при імплементації інтерфейсу в класі має відбуватися звільнення некерованих ресурсів, таких як з'єднання з базою данних, файлові дескриптори, мережеві підключення тощо. Некеровані ресурси потрібно звільняти чим пошвидше, ще до видалення об'єкта з пам'яті коли до нього добереться Garbage Collector. Наприклад, наш клас взаэмодіє з файловою системою ― відкриває файл, читає щось з нього, записує. І краще, чим раніше закінчити роботу із цим файлом, щоб інші програми чи потоки могли його використовувати. І ще така річ, нам самим потрібно явно викликати цей методDispose
, тому що Garbage Collector нічо про нього не знає. Це найкраще робити у блоціtry...finally
, щоб навіть якщо виникне помилка ми змогли звільнити ресурси та правильно очистити пам'ять.class MyFile : IDisposable { private FileStream MyFileStream { get; set; } private StreamWriter MyStream { get; set; } public MyFile(string filePath) { MyFileStream = File.Create(filePath); // Open stream for working MyStream = new StreamWriter(MyFileStream); } public void AddTextToFile(string text) => MyStream?.Write(text); // Free unmanaged resources public void Dispose() { MyStream?.Dispose(); MyFileStream?.Dispose(); } } static void Main(string[] args) { MyFile file = null; try { file = new MyFile("D://file.txt") } finally { file.Dispose(); } // The same - but shorter way using (MyFile file = new MyFile("D://file2.txt")) { file.AddTextToFile("Hello"); } }
Extension методи дозволяють "додавати" методи до існуючих типів без створення нового похідного типу, перекомпіляції або модифікації оригінального типу.Extension метод це особливий статичний метод, який має обов'язково бути членом статичного класу.
public static class StringExtensions { public static int WordCount(this string str) { return str.Split(new char[] { ' ', '.', '?' }, StringSplitOptions.RemoveEmptyEntries).Length; } } string text = "Something like that!"; int amountOfWords = text.WordCount(); // 3
На прикладі показано Extension метод для типу
String
. Статичний клас може мати довільну назву, в той час як назва методу має відрізнятись від уже існуючих методів у класі, який ми розширюємо, або мати іншу сигнатуру. Надалі ми можемо використовувати оголошений нами метод так само як і звичайні методи класу, який ми розширюємо.Дженеріки з'явилися з C# 2.0. Вони принесли в .NET концепт типізованих параметрів ― це дозволяє проектувати класи та методи, які визначають тип членів класу чи методу тільки тоді, коли вони ініціалізовані тим, хто їх використовує.
Наприклад, використовуючи загальний параметр типу
Т
, ми можемо написати єдиний клас, який може використовуватись клієнтським кодом без ризику здійснення boxing-операцій (які самі по собі є важкими операціями і зловживати ними не є добре).class MyGenericClass<T> { private T genericMemberVariable; public MyGenericClass(T value) { genericMemberVariable = value; } public T genericProperty { get; set; } public T GenericMethod(T genericParameter) { Console.WriteLine("Parameter type: {0}, value: {1}", typeof(T).ToString(),genericParameter); Console.WriteLine("Return type: {0}, value: {1}", typeof(T).ToString(), genericMemberVariable); return genericMemberVariable; } }
Як видно з наведеного вище коду,
MyGenericClass
визначений з<T>
.<T>
вказує, щоMyGenericClass
є дженеріком, і типТ
буде визначено пізніше. Можете використовувати будь-які букви або слово замістьT
, це не має значення.class TestGenericClass { static void Main() { // Declare an object of type int. var intGenericClass = new MyGenericClass<int>(10); int val = intGenericClass.GenericMethod(100); /* Output: Parameter type: int, value: 100 Return type: int, value: 10 */ } }
Тепер компілятор призначає тип членів класу на основі типу, переданого програмістом при створенні класу. Наприклад, наступний код використовує тип даних
int
.У C# є Constraint-и для того щоб обмежити типи, які можна використовувати у дженерік класі. Наприклад, якщо через Constraint ми вказуємо що типом
Т
може бути тільки reference type, тобто класи, то ми не зможемо використати value type для створення екземпляру дженерік класу. Відповідно після цього ми не можемо використовувати структурні типи, такі якint
— це викличе помилку компіляції.class MyGenericClass<T> where T: class { } class City { } class Program { static void Main() { // Compile Time Error MyGenericClass<int> intGenericClass = new MyGenericClass<int>(10); MyGenericClass<City> cityGenericClass = new MyGenericClass<City>(new City()); } }
Тут зібрані усі можливі Constraint-и, якими можна обмежувати типи для використання в дженерік класах:
Явно структурному типу даних ми не можемо присвоїти значення
null
. Щоб це зробити, нам потрібно оголосити змінну з модифікатором?
. Цей модифікатор являється аліасом до структуриNullable<T>
int? f = null; Nullable<int> g = null;
Сигнатура
Nullable<T>
:public struct Nullable<T> where T : struct
Коли ми обгортаємо змінну в
Nullable
тип, у нас з'являється новий АРІ для взаємодії з цією змінною:- Властивіть
HasValue
, повертаєtrue
, якщо змінна містить значення, абоfalse
, якщо вонаnull
Value
повертає реальне значення, яке зберігається у змінній, якщоHasValue
дорівнюєtrue
. В іншому випакуValue
викидаєInvalidOperationException
, якщо зміннаnull
.
int? f = 8; if (f.HasValue) { Console.WriteLine($"f is {f.Value}"); } else { Console.WriteLine("f does not have a value"); }
- Властивіть
Делегати — це об'єкти, які вказують на методи; за допомогою них ми можемо викликати методи, які ми присвоїли делегату. Делегати дозволяють представляти методи у вигляді об'єктів і передавати їх до функцій, використовувати як колбеки.
Події — це об'єкти типу делегат, які повідомляють про це що сталась якась подія (відбувся action).
Лямбда-вирази — представляють з себе спрощений запис анонімних методів. Це дозволяє створити лаконічні методи, які можуть повертати якесь значення.
namespace DotNetLecture { // Declate delegate signature delegate void LogMessage(string messageToLog); public class Program { static void LogRedMessage(string message) { Console.ForegroundColor = ConsoleColor.Red; Console.WriteLine(message); } static void LogGreenMessage(string message) { Console.ForegroundColor = ConsoleColor.Green; Console.WriteLine(message); } static void Main(string[] args) { // Create delegate variable and assign method's address to it LogMessage logMessage = LogRedMessage; // Invoke method assigned to this delegate(Invoke syntax) logMessage.Invoke("Hello world!\n"); // Add another method to delegate's invocation list logMessage += LogGreenMessage; // Invoke all methods assigned to this delegate logMessage("Second message"); // Remove the method from the delegate logMessage -= LogGreenMessage; } } }
Замість визначення нового типу делегата, можна використовувати уже визначені делегати Action, Func і Predicate.
- Дженерік делегат Action <T> призначений для посилання на метод, що повертає
void
. Класу цього делегата можна передавати до 16 параметрів довільного типу.static void Main(string[] args) { Action<string, int> printString = (str, num) => Console.WriteLine(str + num); printString("Printed by Action: ", 19); // Printed by Action: 19 }
- Делегати Func можуть використовуватися аналогічним чином. Func дозволяє викликати методи, які щось повертають. Йому так само можна передавати до 16 типів параметрів і 1 тип, який він повертає.
// Accepts 2 `double` parameters, returns `int` Func<double,double, int> add = (a, b) => Convert.ToInt32(a + b); int result = add(4.5, 10.0); Console.WriteLine(result); // 15
- Делегат Predicate використовується для порівняння відповідності деякого об'єкта T певній умові. Він повертає
true
, якщо об'єкт задовільняє умову, іfalse
, якщо ні.Predicate<int> isPositive = x => x > 0; Console.WriteLine(isPositive(10)); // True Console.WriteLine(isPositive(-10)); // False
- Дженерік делегат Action <T> призначений для посилання на метод, що повертає
Події дозволяють сигналізують системі про те, що відбулося певна дія.
Існує така модель: Publisher-Subscriber(Видавець-Підписник). Підписник підписується на подію, визначає обробник і чекає допоки Видавець виконає цю подію, щоб його викликати.
Приклад використання подій та делегатів ви зможете знайти тут.
В C# є масиви, які зберігають в собі набори однотипних об'єктів, але працювати з ними не завжди зручно. Так як масив зберігає фіксовану кількість об'єктів, в випадках коли ми заздалегідь не знаємо, скільки у нас їх буде, набагато зручніше буде застосовувати колекції.
При виборі колекцій визначальну роль може зіграти те, що деякі з них реалізовують стандартні структури даних, такі як
- стек
- чергa
- словник
- хеш-таблиця
...які можуть стати в нагоді для вирішення різних спеціальних завдань. Основою для створення всіх колекцій є реалізація інтерфейсів
IEnumerator
іIEnumerable
.Інтерфейс
IEnumerator
представляє Перераховувач (Перечислитель), який уможливлює послідовний перебір колекції, наприклад в цикліforeach
, або засобами LINQ. А інтерфейсIEnumerable
через свій методGetEnumerator
надає Перераховувач всім класам, які реалізують даний інтерфейс. Тому інтерфейсIEnumerable
є базовим для усіх колекцій.Конкретні методи і способи використання можуть відрізнятися від одного класу колекції до іншого, але загальні принципи будуть одні і ті ж для всіх класів колекцій.
У наведеному прикладі використовуються дві колекції: non-generic —
ArrayList
, та generic —List
. Зараз хорошою практикою вважається використовувати дженерік версії колекцій всюди, де це тільки можливо — через строгу типізацію та зручність у використанні. Більшість колекцій підтримують додавання елементів.class Program { static void Main(string[] args) { // non-generic collection ArrayList ArrayList objectList = new ArrayList() { 0, "someString", 'c', 19.0d }; // generic collection - List List<string> countries = new List<string>() { "Lviv", "Kyiv", "Odessa", "Dnipro" }; object obj = 12.3; objectList.Add(obj); // Error - because object is not a string type countries.Add(obj); objectList.RemoveAt(0); // delete element with index 0 - first element of collection foreach (object o in objectList) { Console.WriteLine(o); } Console.WriteLine($"Total amount of Collection's elements: {objectList.Count}"); } }
Наприклад, в даному випадку додавання проводиться методом
Add
, але для інших колекцій назва методу може відрізнятися. Також більшість колекцій реалізують видалення (в даному прикладі проводиться за допомогою методуRemoveAt
, що видаляє елемент з колекції за індексом елементу). За допомогою властивостіCount
можна подивитися кількість елементів у колекції.Stack<T>
представляє колекцію, яка використовує алгоритм LIFO — last in — first out — ("останній прийшов — першим вийшов"). При такій організації даних кожен наступний доданий елемент поміщається поверх попереднього. Діставання елементів з колекції відбувається в зворотному порядку — витягується той елемент, який знаходиться вище всіх у стеці.У класі
Stack
можна виділити два основні методи, які дозволяють керувати елементами — це:Push
: додає елемент в стек на перше місцеPop
: дістає перший елемент з стекуPeek
:* просто повертає перший елемент з стеку без його видалення
static void Main(string[] args) { var stackCities = new Stack<string>(); stackCities.Push("Lviv"); stackCities.Push("Kyiv"); stackCities.Push("Odessa"); var odessa = stackCities.Pop(); Console.WriteLine("Deleted city: " + odessa) foreach(string city in stackCities) { Console.WriteLine(city); } /* Output: Deleted city: Odessa Lviv Kyiv */ }
На прикладі можна побачити як ми створюємо екземпляр коленції стеку стрічок. Добавляємо 3 міста — "Lviv", "Kyiv", "Odessa" за допомогою методуPush
. Витягуємо елемент який ми додали останнім за допомогою методуPop
та виводимо результати на екран.Dictionary
(словник) зберігає об'єкти, які представляють пару ключ-значення. Його дуже зручно використовувати для того щоб огранізувати відповідність чогось чомусь.Кожен такий об'єкт є екземпляр структури
KeyValuePair<TKey, TValue>
. Завдяки властивостямKey
іValue
, які є у цієї структури, ми можемо отримати ключ і значення елемента в словнику.Dictionary<char, City> cities = new Dictionary<char, City>(); cities.Add('l', new City() { Name = "Lviv" }); cities.Add('k', new City() { Name = "Kyiv" }); cities.Add('o', new City() { Name = "Odessa" }); foreach (KeyValuePair<char, City> keyValue in cities) { // keyValue.Value is instance of class City Console.WriteLine(keyValue.Key + " - " + keyValue.Value.Name); } /* l - Lviv k - Kyiv o - Odessa */ foreach (char c in cities.Keys) { Console.WriteLine(c); } foreach (City c in cities.Values) { Console.WriteLine(c.Name); } // get element by key City city = cities['o']; // object modification cities['k'] = new City() { Name = "Kharkiv" }; // remove by key cities.Remove('l');
Якщо вам потрібно склеїти два значення, щоб повернути їх з функції або помістити два значення в хеш-набір, ви можете використовувати типи
System.ValueTuple
class City { public int code; public string name; public City(int code, string name) { this.code = code; this.name = name; } public (int codeNumber, string name) MethodTuple(string namePrefix) { return (codeNumber: code, name: namePrefix + name); } } static void Main(string[] args) { City yorkCity = new City(12, "York"); var tupleObject = yorkCity.MethodTuple("New "); Console.WriteLine($"City: {tupleObject.name}, code: {tupleObject.codeNumber}"); /* Output: City: New York, code: 12 */ }
// Constructing the tuple instance var tpl = (1, 2); // Using tuples with a dictionary var d = new Dictionary<(int x, int y), (byte a, short b)>(); // Tuples with different names are compatible d.Add(tpl, (a: 3, b: 4)); // Tuples have value semantic if (d.TryGetValue((1, 2), out var r)) { // Deconstructing the tuple ignoring the first element var (_, b) = r; // Using named syntax as well as predefined name Console.WriteLine($"a: {r.a}, b: {r.Item2}"); // `a: 3, b: 4` }
- Створення кортежу
- Використання кортежу для типізування
Dictionary
- Додавання елементів кортежу у
Dictionary
- Повернення значення словника по ключу
- Деструктуризація елементу кортежу
- Доступ до членів кортежу по імені
Іноді при виконанні програми виникають помилки, які важко або неможливо передбачити (наприклад, при передачі файлу по мережі може обірватися підключення і інтернет пропаде). Такі ситуації називаються Exception-ами. Мова C# надає розробникам можливості для обробки таких ситуацій засобами конструкції
try...catch...finally
static void Main(string[] args) { StreamReader file = new StreamReader(@"D:\test.txt"); char[] buffer = new char[10]; try { file.ReadBlock(buffer, 123, buffer.Length); } catch (Exception e) { Console.WriteLine(e.Message); } finally { file?.Close(); } }
- При використанні блоку
try...catch...finally
спочатку виконуються всі інструкції в блоціtry
. - Якщо в цьому блоці не виникло Exception-ів, то після нього виконається блок
finally
і конструкціяtry..catch..finally
завершить свою роботу. - Якщо ж в блоці
try
виникає Exception, то звичайний потік виконання зупиняється і CLR починає шукати блокcatch
, який може обробити цей Exception. - Якщо блок
catch
знайдений, то він виконується, а після його завершення виконається блокfinally
. - Якщо потрібний блок
catch
не знайдений, то програма аварійно завершує своє виконання.
- При використанні блоку
У C# всі типи Exception-ів наслідуються від батьківського класу
Exception
, який додатково поділяється на дві гілкиSystemException
іApplicationException
.SystemException
— це базовий клас для всіх помилок CLR або програмного коду, таких якDivideByZeroException
абоNullReferenceException
і так далі.ApplicationException
використовується для виключень пов'язаних із додатком. Такий тип викключень дуже зручно використовувати для створення своїх кастомних Exception-ів. Для цього треба просто унаслідуватись від класуException
і додати туда те що ви хочете. Далі в цьому класі можна визначати додаткої поля, властивості, методи і тд.public class HttpStatusCodeException : Exception { public HttpStatusCode StatusCode { get; set; } public string ContentType { get; set; } = @"application/json"; // @"text/plain" public HttpStatusCodeException(HttpStatusCode statusCode) { StatusCode = statusCode; } public HttpStatusCodeException(HttpStatusCode statusCode, string message) : base(message) { StatusCode = statusCode; } }
Data IDictionary, що містить дані в парах ключ-значення. HelpLink Може містити URL (або URN) до файлу справки, яка надає вичерпуючу інформацію про причину викиникення помилки. InnerException Ця властивість може використовуватися для створення та збереження ланцюга помилок під час обробки Exception-a. Ви можете використовувати його для створення нового виключення, яке містить попередні Exception-и. Message Надає детальну інформацію про причину винятку. Source Містить назву програми або об'єкту, у якому виникла помилка. StackTrace Містить stack trace який можна використати, щоб визначити де виникла помилка. Stack trace включає назву вихідного файлу та номер рядка програми, якщо доступна debug інформація. Після оператора
throw
вказується об'єктException
-a, в конструктор якого ми можемо передати повідомлення про помилку. Замість загального типу Exception ми можемо вказати об'єкт будь-якого іншого типуException
.catch (DivideByZeroException e) { // thow new exception throw new HttpStatusCodeException(400, "Can't divide by 0"); }
Подібним чином ми можемо генерувати
Exception
-и в будь-якому місці програми. Але існує також і інша форма використання оператораthrow
, коли після цього оператора нічого не вказується.catch (DivideByZeroException e) { //TODO: log error Console.WriteLine("Can't divide by 0"); throw; }
У подібному вигляді оператор throw може використовуватися тільки в блоці
catch
. Різниця між ними у тому, щоthrow
без нічого зберігає початковийstack trace
, у той час якthrow ех
скидуєstack trace
до методу, у якому зараз відбувається обробкаException
-у.
Приклад обробки виняткових ситуацій ви зможете знайти тут.
Принципи чистого коду
Складність: Сім разів відмір, один раз відріж. Мета: Зрозуміти, як писати такий код, який хочеться читати.В перекладі на людську мову — загальноприйняті стандарти написання коду та узгоджені правила, як називати змінні, функції і інше. Це — граматика і орфографія C#, прийнята більшістю .NET-розробників для того, щоб інші девелопери (ви через Х часу) могли легко та швидко зрозуміти, що відбувається у вашому коді та використовувати його, не плутаючись у всіх можливих способах назвати, скажімо, аргумент (і такі правила написання є абсолютно у всіх мовах програмування, не тільки в С#). Довго розповідати про кожне з правил нема сенсу, головне для вас — самостійно ознайомитися з списком більшості поширених стандартів C#.
Хочете бути цивілізованим розробником і мати повагу від колег — прочитайте кілька абзаців з прикладами правильно оформленого коду і дотримуйтеся такого формату, виконуючи таски малі чи великі.Не повторюйся при написанні коду = не прописуй кілька разів те, що можна закодити один раз, і викликати, звертаючись до конкретного модуля. Приклад — веб-додаток, що містить кілька однакових за оформленням блоків, і кожен з них має власний (ідентичний іншим!) опис стилів. Яка ймовірність, що коли потрібно буде внести однакову зміну в усіх цих блоках (вручну, адже ми кілька разів повторюємо той самий набір стилів), розробник пропустить один чи кілька з них? Коли цей принцип порушено і імплементація методу чи навіть класу дублюється без справжньої потреби, а написано кілька сотень тисяч рядків коду (як на будь-якому реальному проекті), то щоб відрефакторити, змінити бізнес логіку чи внести прості зміни до інтерфейсу, доводиться довгенько шукати по імені методу нещасний кусок коду, часто для того, щоб поміняти у цьому лише 1 цифру.
Кароч, так не робиться 😐. Щоб досягнути DRY у вашому коді — діліть ваш код на маленькі кусочки, бачите що частина логіки повторюється — одразу виносьте, компонуйте функції. Чому потрібен DRY? Чим менше коду, тим краще. Його легше підтримувати, менше часу йде на те, щоб у ньому розібратися і також зменшується кількість багів.Цей принцип говорить сам за себе ― простий і лаконічний код легше зрозуміти іншим розробникам і тобі, коли ти повернешся до нього за якийсь час. Він формулюється так — "кожен метод повинен вирішувати лише одну маленьку проблему, а не мати багато різних засобів вжитку". Якщо у методі багато умов, то розбийте їх на менші методи. Це буде легше читати, підтримувати і це також допоможе знайти помилки набагато швидше. Щоб продемонструвати KISS, найчастіше приводять приклад із визначенням дня тижня:
Simple
string weekday(int day) { switch (day) { case 1: return "Monday"; case 2: return "Tuesday"; case 3: return "Wednesday"; case 4: return "Thursday"; case 5: return "Friday"; case 6: return "Saturday"; case 7: return "Sunday"; default: throw new Exception("Day must be in range 1 to 7"); } }
Stupid
string weekday(int day) { if ((day < 1) || (day > 7)) { throw new Exception("Day must be in range 1 to 7"); } string[] days = { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" }; return days[day - 1]; }
На слайді показано два методи для вирішення цієї задачі:- Перше рішення просте як двері ― простий
switch
з дефолтним case-ом у випадку якщо день не знайдено. - Другий метод теж робочий, але для того щоб його зрозуміти, потрібно довший час вчитуватися.
Щоб досягнути KISS ― старайтесь писати максимально простий код. Якщо бачите важку (нечитабельну) ділянку коду, пошукайте більш лаконічне вирішення тої ж самої задачі, і, відрефакторивши написане, ви здивуєтеся, що кусок на 200 рядків насправді не такий вже й необхідний!- Перше рішення просте як двері ― простий
SOLID — це 5 принципів об'єктно-орієнтованого програмування, які описують архітектуру програмного забезпечення:
А якщо простіше, то це правила, дотримуючись яких ви будете писати легкий для розумііння, радегування чи повторного використання код.
Принцип єдиної відповідальності. Він означає, що кожен клас чи структура повинні мати лише одне завдання або вирішувати лише одну таску. Всі члени класу пишуться для виконання даної для нього задачі, і в ньому не знаходиться жодна строчка коду, яка не відноситься до вказаної для цього блоку задачі. Якщо ми дотримуємося цього принципу, то ми визначаємо класи за їх задачами ще на етапі проектування програми.
😢
class Task { public string Title { get; set; } public string Description { get; set; } // Adds task in Database public bool Add(Task tast) { // Internal realization (Insert into DB) } // Estimate Task's duration public void EstimateTaskDuration(Task tast) { // Calculation Task's difficulty, estimating } }
😎
class Task { public string Title { get; set; } public string Description { get; set; } // Adds task in Database public bool Add(Task tast) { // Internal realization (Insert into DB) } } class TaskEstimator { // Estimate Task's duration public void EstimateTaskDuration(Task tast) { // Calculation Task's difficulty, estimating } }
Я навів в приклад класTask
— він зберігає задачу в нашу базу даних і обчислює час, необхідний для вирішення задачі.
Робимо висновок, що він не відповідає принципу Single Responsibility Principle. Чому ми не хочемо, щоб він виконував і інші корисні функції, наприклад, визначав необхідний для виконання завдання час? Тому що якщо через деякий час у замовника поміняються параметри виконання задач (наприклад, через реліз або зміни в чисельності команди програмістів), нам доведеться переписувати класTask
відповідно до змін у вхідних даних, і тестити чи не зламався при тому інший функціонал, якийTask
виконує. Згідно з Single Responsibility Principle ми маємо створити окремий клас для розрахунку часу на виконання завдань, який вже буде керуватися бізнес-логікою та іншими вхідними даними.Принцип відкритості/закритості. Наш клас повинен бути відкритим для масштабування, але закритим для модифікацій. Наш модуль повинен бути розроблений так, щоб дописувався він тільки при створенні нових вимог ― але тих, що стосуються початкової задачі. «Закритий для модифікацій» означає, що клас вже повністю готовий і життєздатний, його задачі і призначення не міняються, отже ми не переписуємо його істотно, окрім як в випадку виправлення багів. У C# це досягаться через принцип успадкування.
👎
class Mockup { public string ImageType { get; set; } public Image ConvertImage(Image img) { if (ImageType == "tiff") { // Convert Image to the tiff format } if (TypeReport == "cdr") { // Convert Image to the cdr format } } }
👍
abstract class Mockup { public virtual void ConvertImage(Image img) { // Base realization that common for each format } } class MockupTiff: Mockup { public override Image ConvertImage(Image img) { // Convert Image to the tiff format } } class MockupCdr: Mockup { public override Image ConvertImage(Image img) { // Convert Image to the cdr format } }
Розглянемо приклад з мокапом ― прототипом сторінки. Проблема даного класу в тому, що коли замовник захоче подивитись на створений дизайнерами мокап, але не зможе відкрити зображення формату tiff чи cdr, розробнику потрібно буде вносити новий формат картинки, наприклад png. Через це ми будем змушені додати нову умову
if
, що суперечить Open Closed Principle.На другому прикладі показано як це можна вирішити ― є базовий абстрактний клас
Mockup
, який частково реалізовує конвертацію картинки, а дочірні класи реалізують конвертацію картинки в необхідний формат. І якщо ми захочемо добавити ще один формат, нам просто треба буде створити ще один клас, який буде наслідувати відMockup
і реалізовувати потрібний нам метод конвертації.За принципом пiдстановки Лiсков ми повинні мати можливість використовувати будь-який дочірній клас замість батьківського таким же чином, не вносячи зміни. Дочірній клас не може порушувати визначення типу приведені у батькіському класі та суперечити його поведінці власним функціоналом.
🤦♂️
abstract class Developer { public virtual string CodeWebApp() { return "Coding Front-End Web App"; } public virtual string CodeServer() { return "Coding Back-End Server"; } } class JavaScriptDeveloper: Developer { public override string CodeWebApp() { return "Coding Front-End with Angular"; } public override string CodeServer() { return "Coding Back-End with Node.js"; } } class CSharpDeveloper: Developer { // C# Developer can't create Front-End App public override string CodeWebApp() { throw new NotImplementedException(); } public override string CodeServer() { return "Coding Back-End with ASP.Net"; } }
💁♂️
interface IFrontend { string CodeWebApp(); } interface IBackend { string CodeServer(); } class JavaScriptDeveloper: IFrontend, IBackend { public string CodeWebApp() { return "Coding Front-End with Angular"; } public string CodeServer() { return "Coding Back-End with Node.js"; } } class CSharpDeveloper: IBackend { public string CodeServer() { return "Coding Back-End with ASP.Net"; } }
От як це можна проілюструвати:Developer
є батьківським класом дляJavaScriptDeveloper
iCSharpDeveloper
. Наш класDeveloper
може створювати бекенд і фронтенд додатки. Здавалося б усе добре.JavaScriptDeveloper
успішно реалізовує 2 методи. А от зCSharpDeveloper
не все так просто, він може написати сервер на ASP.NET-і, але зовсім не може в фронтенд. І якщо ми спробуємо все ж таки отримати від нього фронтенд, то зловимо помилку ― exception. По-хорошому нам потрібно розділити функціоналDeveloper
на 2 частини:IFrontend
таIBackend
, і реалізувати їх відповідно до призначення похідних класів:JavaScriptDeveloper
у нас реалізовує іIFrontend
іIBackend
, аCSharpDeveloper
тількиIBackend
.Принцип розділення інтерфейсів говорить, що не треба пакувати разом всі інтерфейси підряд, треба їх розділяти за призначеннями, щоб користувачі могли вибірково імплементувати лише ті, які використовують, а не всі підряд наявні в програмі.
💩
interface IDeveloper { string CodeDesktop(); string CodeServer(); }
🎉
interface IDesktop { string CodeDesktop(); } interface IBackend { string CodeServer(); }
Давайте припустимо, що у нас є Інтерфейс
IDeveloper
, який тепер вміє створювати сервер і десктопний додаток. Як і до того, у нас єJavaScriptDeveloper
таCSharpDeveloper
, які можуть використати цей функціонал за призначенням. Для JavaScript додаток писався би під Electron, а на C# це був би WPF додаток. Все класно, всі задоволені, але ні, бо наш босс несподівано каже, що додатки на Electron в нього лагають і взагалі дорого платити цим JavaScript-розробникам. Ми урізаєм десктоп-проекти на JavaScript, пишем тепер тільки на WPF. І таким чином ми ломаємо принцип Interface Segregation, бо наш клас не може не виконувати свій функціонал, і виходить що JavaScript розробники все ще пишуть десктоп-проекти.Рішенням цієї проблеми буде знову ж таки розділення інтерфейсу на кілька:
IDesktop
таIBackend
. Це нагадує попередній приклад, але тут ми вирішуєм іншу проблему ― не даємо класу робити більше ніж потрібно.І тепер останній і, мабуть, найважчий для розуміння принцип ― інверсія залежностей.
- Класи високого рівня не повинні залежати від класів низького рівня, при цьому обидва мають залежати від абстракцій.
- Абстракції не повинні залежати від деталей, але деталі мають залежати від абстракцій.
Що це значить? А це значить, що класи високого рівня реалізують бізнес-правила або логіку в системі. Низькорівневі класи займаються більш детальними операціями, як от роботою з базою даних, передачею повідомлень в операційну систему ― і так далі. Щоб досягти інверсії залежностей ми повинні тримати ці високорівневі і низькорівневі класи настільки слабозв'язаними наскільки можливо. І якраз для цього ми пишемо їх залежними від абстракцій, а не один від одного.
😭
class Email { public void Send() { // Code to send email-letter } } class Notification { private Email email; public Notification() { email = new Email(); } public void EmailDistribution() { email.Send(); } }
🤩
interface IMessenger { void Send(); } class Email: IMessenger { public void Send() { /* Code to send email-letter */ } } class SMS: IMessenger { public void Send() { /* Code to send SMS */ } } class Notification { public IMessenger Messenger { get; set; } public Notification(IMessenger mess) { Messenger = mess; } public void Notify() { Messenger.Send(); } } static void Main(string[] args) { var notification = new Notification(new Email()); notification.Notify(); // Sent email notification.Messenger = new SMS(); // Change the provider notification.Notify(); // Sent SMS }
Давайте розглянемо цей принцип на прикладі розсилки повідомлень. На першому зразку коду клас
Notification
повністю залежить від класуEmail
, тому що він відправляє тільки один тип повідомлень. Що якщо ми захочемо відправляти повідомлення якимсь іншим способом? Тоді нам треба буде копатися у всій системі повідомлень. Це є ознакою того що система є занадто тісно зв'язаною.Щоб зробити її слабо зв'язаною в цьому випадку нам потрібно абстрагуватися від провайдера відправки повідомлень
Email
. Для цього ми створюємо інтерфейсIMessenger
з методомSend
і реалізуємо його у двох класах ―Email
іSMS
. КласNotification
ми складаємо так, щоб відв'язатись від конкретної реалізації розсилки повідомлень. В цьому випадку ми можемо використати принцип Dependency Injection, прокинувши об'єктMessenger
через конструктор. І в результаті ми відправлятимемо повідомлення того класу, з яким зараз працюємо. Якщо ми створюємоNotification
зEmail
Messenger-ом, відправляєтья електронний лист. Далі ми захотіли змінити провайдера і присвоїли властивостіMessenger
класуSMS
, тому наступний виклик методуNotify
уже надішле SMS-ку.
Кожен принцип SOLID пропонує нам шлях до написання логічного, надійного та зрозумілого коду, а мова С#, при дотриманні цих принципипів, дає змогу писати великі програми та легко розширювати їх.