.NET ecosystem and C# best practices

Translated into:EN
UA

Іван Гедзь

Іван працює Full Stack веб-розробником у Binary Studio. Пише на C# і TypeScript. З хобі ― слухати музику, грати в аркади, займатися йогою. Вирішив вперше записати лекцію для Академії, щоб тобі було легше розібратися з екосистемою .NET.

Привіт!
Лекція про екосистему .NET та кращі практики написання C#-коду почнеться за 5..4..3... Але спочатку 🥁 кілька дісклеймерів про саму ж лекцію:
  1. багато з чого, про що йдеться у лекції, ви вже мабуть чули (або ні);
  2. деякі теми та терміни висвітлюються поверхнево, не вдаючись у деталі;
  3. лекція може здатися затягнутою, але believe me — це швидше, ніж прошуршати документацію MSDN-у в пошуках потрібної інфи 😝.

Вам також знадобиться стартовий пакет розробника .NET: .NET, Git, Visual Studio або Rider.

Розділ 1 нагору

Огляд .NET платформи

Складність: Easy peasy lemon squeezy. Мета: Почитати/послухати про платформу .NET в загальному (довго не буде).

Зараз компанія Microsoft вкладає досить багато ресурсів у розвиток .NET та їхньої хмарної платформи — Azure (Ажур, Ежур, Ейжа) та найбільше контрибьютить в open source. Інфраструктура .NET стрімко росте та розвивається, постійно створюютсья нові інструменти для роботи з нею, додаються інтеграції з іншими сервісами.

  • dotnet-platform

    Шляхи створення .NET-додатків:

    1. .NET Framework — розробка під Windows настільних додатків на Windows Forms, WPF, веб-серверів на ASP.NET та WCF
    2. .NET Core — розробка кросплатформних веб-додатків за допомогою ASP.NET Core, створення гібридних додатків з допомогою Universal Windows Platform, яка дозволяє запускати програму написану на цій технології на Windows-машині, Xbox, Hololens
    3. Xamarin — платформа для створення мобільних додатків для iOS i Android, використовуючи C#, XML та XAML

    Код, який написаний під спеціальний фреймворк, як WPF, ASP.NET Core чи Android, не можна перевикористати на іншій платформі, тому що він заточений для роботи з так званими platform-specific API, який відрізняється у них всіх. Щоб можна було повторно використовувати код бізнес-логіки, хелпер-методів, моделей, класів і так далі було створено .NET Standart, який надає набір доступних АPI, які однаково працюють у всіх десктопних програмах, веб-серверах, мобільних додатках, іграх та хмарних службах незалежно від операційної системи і платформи.

    p
  • dotnet5-platform

    З листопада 2021 року випущено .NET 6. Що стало серйозним вдосконаленням системи розробки в цілому. Головним нововведенням стала підтримка Linux, macOS, iOS, Android, tvOS, watchOS і WebAssembly. В результаті стало можливим створювати додатки для різних платформ на загальній базі коду з однаковим процесом збирання, незалежно від типу додатка. Тож тепер ви можете розробляти за допомогою Visual Studio, Visual Studio для Mac, Visual Studio Code або будь-якої іншої IDE за допомогою dotnet CLI

  • nuget-logo

    Кожному програмісту рано чи пізно гарантовано потрібно імплементовувати функціонал, який частково або повністю хтось раніше вже створив і навіть опублікував (як правило у вигляді DLL бібліотеки). Розробники називають такі модулі "пакетами", в які складено скомпільований код, додаткові файли-ассети та маніфест, що пояснює мету та спосіб використання пакету. У більшості мов програмування є власні платформи для обміну такими корисними модулями, у .NET це NuGet, пітримуваний Microsoft-ом. Розробники, які створили крутий інструмент чи, наприклад, бібліотеку для роботи з файловою системою, мають можливість опублікувати свою роботу як NuGet-пакет в вигляді zip-файлу з розширенням .nupkg. Ви можете шукати та скачувати модулі, які пришвидшать розробку вашого додатку, з центрального репозиторію NuGet Gallery ― він налічує вже близько 100000 унікальних пакетів і там може знайтись щось корисне.

Розділ 2 нагору

.NET під капотом

Складність: Hard as hell 🔥 Мета: Зрозуміти SDK.
  • Сьогодні у .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 передаються за посиланням.

  • stack-and-heap

    У .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
    boxing-unboxing
Розділ 3 нагору

Найважливіші теми 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 свого боку інтерфейс дозволяє визначити функціональні можливості або функції, але не може їх реалізувати.

    Клас імплементує інтерфейс та обов'язково реалізує ці методи. Розглянемо кілька ключових відмінностей між ними:

    1. Інтерфейс не може мати модифікаторів доступу до членів — все що є в інтерфейсі по дефолту є публічним. У абстрактного класу все залишається як і у звичайного класу.
      interface TestInterface  
      {  
          // Causes syntax error
          protected void GetMethod();  
      
          public string PublicProp { get; set; } 
      }
      abstract class TestAbstractClass  
      { 
          public abstract string GetStuff();
      
          public abstract void DoSmth();
      }
        
    2. В Інтерфейсі ми можемо лише описати сигнатуру методу без його імплементації. А у абстрактному класі можуть знаходитися як абстрактні методи та властивості, так і не абстрактні — з повною або частковою реалізацією.
      interface TestInterface  
      {  
          // Only signature
          void GetMethod();  
      }
      
      
      
        
      abstract class TestAbstractClass  
      { 
          // Complete method implementation
          public string GetStuff()
          {
              Console.WriteLine("Stuff");
              return "Stuff";
          }  
      }
    3. Ми не можемо оголосити конструктор у тілі інтерфейсу ні з модифікатором доступу, ні без нього. У абстрактному класі ми можемо оголошувати конструктори з тими самими правилами, що і у звичайних класах. Він здебільшого використовується для виклику у конструкторі похідного класу, щоб не дублювати код ініціалізації полів чи властивостей абстрактного класу.
      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;
          }  
      } 
    4. Ми не можемо явно створити інстанс інтерфейсу чи абстрактного класу викликавши конструкор. Хоча нагадаю, що у абстрактного класу він може бути.
      static void Main(string[] args)
      {
          // Causes syntax error
          TestInterface testInterface = TestInterface();
      
          // Causes syntax error as well
          TestAbstractClass abstractClass = TestAbstractClass();
      }
    5. Абстрактний клас може містити поля і властивості, інтерфейс — лише властивості.
      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-и, якими можна обмежувати типи для використання в дженерік класах:

      constraints-types
  • Явно структурному типу даних ми не можемо присвоїти значенняnull. Щоб це зробити, нам потрібно оголосити змінну з модифікатором ?. Цей модифікатор являється аліасом до структури Nullable<T>

    int? f = null;
    Nullable<int> g = null; 

    Сигнатура Nullable<T>:

    public struct Nullable<T> where T : struct

    Коли ми обгортаємо змінну в Nullable тип, у нас з'являється новий АРІ для взаємодії з цією змінною:

    • Властивіть HasValue, повертає true, якщо змінна містить значення, або false, якщо вона null
    • int? f = 8;
      if (f.HasValue)
      {
          Console.WriteLine($"f is {f.Value}");
      }
      else
      {
          Console.WriteLine("f does not have a value");
      }
    • Value повертає реальне значення, яке зберігається у змінній, якщо HasValue дорівнює true. В іншому випаку Value викидає InvalidOperationException, якщо змінна null.
  • Делегати — це об'єкти, які вказують на методи; за допомогою них ми можемо викликати методи, які ми присвоїли делегату. Делегати дозволяють представляти методи у вигляді об'єктів і передавати їх до функцій, використовувати як колбеки.

    Події — це об'єкти типу делегат, які повідомляють про це що сталась якась подія (відбувся action).

    Лямбда-вирази — представляють з себе спрощений запис анонімних методів. Це дозволяє створити лаконічні методи, які можуть повертати якесь значення.

    • lambdaExpressionStructure
    • 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
    • delegateEventFlow

      Події дозволяють сигналізують системі про те, що відбулося певна дія.

      Існує така модель: Publisher-Subscriber(Видавець-Підписник). Підписник підписується на подію, визначає обробник і чекає допоки Видавець виконає цю подію, щоб його викликати.

      Приклад використання подій та делегатів ви зможете знайти тут.

  • В C# є масиви, які зберігають в собі набори однотипних об'єктів, але працювати з ними не завжди зручно. Так як масив зберігає фіксовану кількість об'єктів, в випадках коли ми заздалегідь не знаємо, скільки у нас їх буде, набагато зручніше буде застосовувати колекції.

    При виборі колекцій визначальну роль може зіграти те, що деякі з них реалізовують стандартні структури даних, такі як

    • стек
    • чергa
    • словник
    • хеш-таблиця

    ...які можуть стати в нагоді для вирішення різних спеціальних завдань. Основою для створення всіх колекцій є реалізація інтерфейсів IEnumerator і IEnumerable.

    Інтерфейс IEnumerator представляє Перераховувач (Перечислитель), який уможливлює послідовний перебір колекції, наприклад в циклі foreach, або засобами LINQ. А інтерфейс IEnumerable через свій метод GetEnumerator надає Перераховувач всім класам, які реалізують даний інтерфейс. Тому інтерфейс IEnumerable є базовим для усіх колекцій.

    Конкретні методи і способи використання можуть відрізнятися від одного класу колекції до іншого, але загальні принципи будуть одні і ті ж для всіх класів колекцій.

    • collectionsHierarchy
    • У наведеному прикладі використовуються дві колекції: non-genericArrayList, та genericList. Зараз хорошою практикою вважається використовувати дженерік версії колекцій всюди, де це тільки можливо — через строгу типізацію та зручність у використанні. Більшість колекцій підтримують додавання елементів.

      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

      У класі 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`
      }
      1. Створення кортежу
      2. Використання кортежу для типізування Dictionary
      3. Додавання елементів кортежу у Dictionary
      4. Повернення значення словника по ключу
      5. Деструктуризація елементу кортежу
      6. Доступ до членів кортежу по імені
  • Іноді при виконанні програми виникають помилки, які важко або неможливо передбачити (наприклад, при передачі файлу по мережі може обірватися підключення і інтернет пропаде). Такі ситуації називаються 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();
          }
      }
      1. При використанні блоку try...catch...finally спочатку виконуються всі інструкції в блоці try.
      2. Якщо в цьому блоці не виникло Exception-ів, то після нього виконається блок finally і конструкція try..catch..finally завершить свою роботу.
      3. Якщо ж в блоці try виникає Exception, то звичайний потік виконання зупиняється і CLR починає шукати блок catch, який може обробити цей Exception.
      4. Якщо блок catch знайдений, то він виконується, а після його завершення виконається блок finally.
      5. Якщо потрібний блок catch не знайдений, то програма аварійно завершує своє виконання.
    • У C# всі типи Exception-ів наслідуються від батьківського класу Exception, який додатково поділяється на дві гілки SystemException і ApplicationException.

      SystemException — це базовий клас для всіх помилок CLR або програмного коду, таких як DivideByZeroException або NullReferenceException і так далі.

      exceptionClassHierarchy
    • 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;
          }
      }
    • DataIDictionary, що містить дані в парах ключ-значення.
      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-у.

    Приклад обробки виняткових ситуацій ви зможете знайти тут.

Розділ 4 нагору

Принципи чистого коду

Складність: Сім разів відмір, один раз відріж. Мета: Зрозуміти, як писати такий код, який хочеться читати.
  • В перекладі на людську мову — загальноприйняті стандарти написання коду та узгоджені правила, як називати змінні, функції і інше. Це — граматика і орфографія 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];
    }
    
    
       
    На слайді показано два методи для вирішення цієї задачі:
    1. Перше рішення просте як двері ― простий switch з дефолтним case-ом у випадку якщо день не знайдено.
    2. Другий метод теж робочий, але для того щоб його зрозуміти, потрібно довший час вчитуватися.
    Такий код існує повсюди, але він дійсно незручний і виглядає непрофесійно, 99% програмістів вибрали б працювати з чимось схожим на перший варіант.
    Щоб досягнути 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 i CSharpDeveloper. Наш клас 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. Це нагадує попередній приклад, але тут ми вирішуєм іншу проблему ― не даємо класу робити більше ніж потрібно.

    • І тепер останній і, мабуть, найважчий для розуміння принцип ― інверсія залежностей.

      1. Класи високого рівня не повинні залежати від класів низького рівня, при цьому обидва мають залежати від абстракцій.
      2. Абстракції не повинні залежати від деталей, але деталі мають залежати від абстракцій.

      Що це значить? А це значить, що класи високого рівня реалізують бізнес-правила або логіку в системі. Низькорівневі класи займаються більш детальними операціями, як от роботою з базою даних, передачею повідомлень в операційну систему ― і так далі. Щоб досягти інверсії залежностей ми повинні тримати ці високорівневі і низькорівневі класи настільки слабозв'язаними наскільки можливо. І якраз для цього ми пишемо їх залежними від абстракцій, а не один від одного.

      😭

      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 пропонує нам шлях до написання логічного, надійного та зрозумілого коду, а мова С#, при дотриманні цих принципипів, дає змогу писати великі програми та легко розширювати їх.

― That's all, folks! 🐷

Я можу довго говорити про C# та .NET, але в цій лекції поділився основним, на мою думку, для вас на даний момент. На тому все, дякую за увагу, ставте оцінку лекції в ваших особистих кабінетах, залишайте відгук, задавайте питання, робіть домашку і до зустрічі на код-рев'ю! 👋