Лабораторна робота 2
Створення та використання класів
1 Завдання на лабораторну роботу
1.1 Індивідуальне завдання
Створити консольний застосунок C#, який реалізує класи відповідно до завдання. Наведені в таблиці класи повинні відповідати тематиці, обраній у першій лабораторній роботі.
Клас представлення групи сутностей | Клас-сутність | Допоміжний клас |
---|---|---|
Група | Студент | Адреса |
Сесія | Навчальний предмет | Викладач |
Місто | Район | Країна |
Область | Місто | Країна |
Спортивна секція | Учасник | Адреса |
Футбольний клуб | Гравець | Країна |
Музичний гурт | Учасник | Країна |
Музичний гурт | Альбом | Країна |
Альбом | Пісня | Автор тексту |
Квартира | Кімната | Адреса |
Збірка оповідань різних авторів | Оповідання | Автор |
Художник | Твір | Країна |
Метрополітен | Станція | Місто |
Залізнична ділянка | Станція | Країна |
Письменник | Роман | Країна |
Якщо список тем був розширений під час виконання попередньої лабораторної роботи, відповідні класи треба узгодити з викладачем.
Група сутностей повинна містити масив конкретних сутностей.
Для форматованого виведення даних на консоль слід створити окремі допоміжні класи. В цілому структура класів повинна відповідати вимогам принципу Єдиної відповідальності (Single Responsibility Principle).
Поле типу допоміжного класу повинно бути розташоване в одному з класів (групи сутностей або сутності) залежно від варіанту завдання. В окремому класі передбачити методи для пошуку та сортування даних, визначені в індивідуальному завданні попередньої лабораторної роботи. Класи повинні містити загальнодоступні властивості. Слід додати XML-коментарі до класів та публічних методів.
Дані, які потрібно шукати, будуть вводитися з клавіатури під час виконання. Дані тесту повинні бути підготовлені таким чином, щоб пошук дав більше одного результату.
1.2 Розв'язання квадратного рівняння
Створити три варіанти функції для розв'язання квадратного рівняння:
- Перша функція повинна отримувати як параметри коефіцієнти рівняння, а також корені як параметри з атрибутом
out
. Функція повинна повертати кількість коренів та -1, якщо коренів безмежна кількість. - Друга функція повинна отримувати як параметри коефіцієнти рівняння та повертати структуру, в якій зберігаються три значення: кількість коренів, перший та другий корінь.
- Третя функція повинна отримувати як параметри коефіцієнти рівняння та повертати кортеж, який складається з трьох значень: кількість коренів, перший та другий корінь.
Продемонструвати роботу всіх функцій.
1.3 Створення класу Complex
Створити клас Complex
(комплексне число), перевантажити операції +
, -
, *
, /
і
операцію неявного приведення до типу string
. Під час тестування використовувати ініціалізатори
об'єктів.
1.4 Розширення класу String
Розширити стандартний клас System.String
методом видалення зайвих пропусків (декілька пропусків замінювати
одним).
1.5 3D-точка
Реалізувати структуру (struct
) для представлення точки в тривимірному просторі. Реалізувати
метод обчислення відстані від точки до початку координат. В окремому класі здійснити тестування структури.
1.6 Клас для подання квадратного рівняння (додаткове завдання)
Створити клас "Квадратне рівняння", корені якого – властивості з доступом для читання. Додати також індексатор для доступу до коренів за індексом.
2 Методичні вказівки
2.1 Простори імен
Простори імен (namespaces) забезпечують засоби логічного групування класів та інших просторів імен. Синтаксис і використання просторів імен у C# в основному аналогічні C++. Наприклад, так описується простір імен:
namespace NewSpace { public class SomeClass { public void f() { } } public struct SomeStruct { } }
Простір імен можна продовжувати в інших файлах. Якщо не оголошено жодного простору імен, передбачається стандартний (глобальний) простір імен.
Як видно з наведеного прикладу, простір імен групує класи (та інші типи). Простори можна вкладати один в інший. Замість створення декількох вкладених просторів можна описувати простори імен з крапками. Наприклад, замість такого опису
namespace First { namespace Second { namespace Third { } } }
можна застосувати більш компактний:
namespace First.Second.Third { }
Версія C# 10 дозволяє визначати простір імен до кінця файлу без зайвих фігурних дужок (file-scoped namespace declaration). Якщо на початку файлу вказати заголовок простору імен, який закінчується крапкою з комою, це означає, що всі визначення до кінця файлу належать до цього простору імен:
namespace SomeSpace; // // Увесь код визначений у просторі імен SomeSpace //
Для підключення всіх описів простору імен можна використовувати директиву using
:
using NewSpace; namespace OtherSpace { public class Test { public void g() { SomeClass sc = new SomeClass(); // ім'я типу без префіксу імені простору sc.f(); } } }
За допомогою using
у C# можна створювати синоніми для просторів імен. Наприклад:
using S = System; ... S.Console.WriteLine();
Як видно з наведеного прикладу, до елементів простору імен можна звертатися через крапку. Але більш адекватним
є використання операції ::
. В такому випадку одразу видно, що йдеться про синонім:
using S = System; ... S::Console.WriteLine();
У C# визначене контекстно-залежне ключове слово global
– синонім глобального простору
імен. Наприклад:
global::System.Console.WriteLine();
Цей синонім застосовують для запобігання конфліктам імен.
У версії C# 10 розширені можливості використання директиви using
. Зокрема, можна додати модифікатор global
до
будь-якої using
-директиви. Тоді директива застосовуватиметься до всіх сирцевих файлів
у проєкті.
2.2 Класи. Інкапсуляція. Властивості
2.2.1 Визначення класів. Інкапсуляція
Весь код, що виконується, у C# знаходиться всередині методів і властивостей класів, структур, або інтерфейсів. Нижче наводиться приклад опису класу:
class Rectangle { public double width; public double height; public double Area() { return width * height; } }
На відміну від C++, неабстрактні та нечасткові методи завжди реалізуються всередині визначення класу або структури.
Окрім полів width
і height
, клас містить метод Area()
для підрахунку площі
прямокутника. В тілі методу здійснюється обчислення добутку полів. Значення, які зберігаються в полях класу, різні
для різних об'єктів. Метод ми викликаємо для певного об'єкта з визначеними значеннями width
і height
.
Ці поля можна використовувати безпосередньо, або посилаючись на поточний об'єкт через спеціальне посилання this
:
public double Area() { return this.width * this.height; }
Використання this
багато в чому аналогічно відповідному вказівнику в C++. Зазвичай його явно використовують
для запобігання конфліктам імен. У наведеному вище прикладі його використання не доцільне.
Недолік попереднього прикладу – наявність публічних полів. Це є безумовним порушенням принципу інкапсуляції. Як і в більшості мов об'єктно-орієнтованого програмування, мова C# підтримує різні рівні доступу до елементів:
- закритий
(
private
) – елементи доступні лише в межах класу; цей рівень визначено як усталений, елементи без будь-яких модифікаторів мають доступprivate
; - захищений (
protected
) – елементи доступні в цьому класу і всіх похідних класах; - відкритий (
public
) – елементи доступні з будь-якого коду проєкту (якщо клас публічний); - внутрішній (
internal
) – елементи доступні з будь-якого коду в межах складання (assembly); - захищений внутрішній (
protected internal
) – елементи доступні з будь-якого коду в межах складання, а також в усіх похідних класах (якщо клас публічний); - закритий захищений (
private protected
) забезпечує доступ для цього класу і похідних класів, оголошених лише в тому ж складанні.
На відміну від C++, C# вимагає окремої специфікації доступу для кожного елемента (або групи елементів даних, описаних з одним специфікатором типу) без двокрапки.
Сам клас
може бути оголошений як public
,
інакше він буде доступний лише у межах складання
(можна також явно додати модифікатор internal
). Клас може бути визначений у просторі імен чи
в іншому класі.
Поля слід визначити як приватні (закриті). Під час створення об'єкта класу поля ініціалізуються усталеними значеннями. C# допускає ініціалізацію полів початковими значеннями:
public class Rectangle { private double width = 10; private double height = 20; public double Area() { return width * height; } }
Класи у C# завжди є типами-посиланнями. Можна спочатку визначити посилання на об'єкт:
Rectangle rectangle;
Таке посилання буде невизначеним, що не бажано. Традиційно для того, щоб вказати, що посилання не посилається на
жодний об'єкт, використовують константу null
:
Rectangle rectangle = null;
У сучасних версіях C# використання null
вважається небажаним, оскільки спроба застосувати
будь-які дії до об'єкта призводить до генерації винятку NullReferenceException
. Окрім того, походження значення
іноді важко відстежити. Такий результат може виникати з різних причин. Якщо ми все одно бажаємо використовувати
null
, слід вказати що тип-посилання є null
-сумісним.
Починаючи з версії C# 8.0 null
-сумісність можна визначати також для типів-посилань:
Rectangle? rectangle = null;
Для створення об'єкта класу спочатку слід визначити посилання, а потім записати в нього адресу нового об'єкта, розташованого в динамічній пам'яті:
rectangle = new Rectangle();
В нашому випадку немає сенсу використовувати null
-сумісний тип. Крім того, можна
поєднати визначення посилання та створення об'єкта в одному рядку. Також можна не повторювати ім'я класу після new
.
Для створеного об'єкта можна викликати метод Area()
:
Rectangle rectangle = new(); Console.WriteLine(rectangle.Area());
Клас Rectangle
поки не може бути використаний для представлення різних прямокутників, оскільки значення width
і height
не
можна змінити й навіть прочитати. Для кожного об'єкта типу Rectangle
метод Area()
завжди
повертатиме 200. Окрім приховування даних, інкапсуляція передбачає також надання механізму контрольованого читання
та модифікації полів. Традиційний підхід, реалізований практично в усіх об'єктно-орієнтованих
мовах полягає у створені спеціальних методів доступу – гетерів та сетерів, які забезпечують читання і запис
даних з можливістю контролю коректності доступу. В простішому випадку коректність може не перевірятися:
public class Rectangle { private double width = 10; private double height = 20; public double GetWidth() { return width; } public void SetWidth(double width) { this.width = width; } public double GetHeight() { return height; } public void SetHeight(double height) { this.height = height; } public double Area() { return width * height; } }
Примітка: оскільки з різних точок зору доцільно використовувати імена параметрів сетерів, які збігаються з відповідними
полями, виникають конфлікти імен, тому доцільно використання this
перед іменами полів.
Тепер можна читати й змінювати значення полів:
Rectangle rectangle = new(); rectangle.SetWidth(20); rectangle.SetHeight(30); Console.WriteLine(rectangle.GetWidth() + " " + rectangle.GetHeight()); Console.WriteLine(rectangle.Area()); // 600
2.2.2 Властивості
Замість функцій доступу для реалізації інкапсуляції мова C# пропонує підхід, побудований на використанні так званих властивостей.
Властивість – це елемент класу (структури), звертання до якого здійснюється як до поля, але насправді під час доступу до нього виконується визначений програмний код. У більшості випадків властивості представляють фактичні поля класу, але властивість також може бути не пов'язана з полем.
У C# властивість описується разом зі своїм кодом. Після імені властивості міститься блок, у якому виділяються блоки set
{}
(для
запису значення властивості) і get
{}
(для читання значення властивості).
У блоці запису можна використовувати ключове слово value
, що має зміст записуваного значення.
Властивості можна описати тільки для читання (відсутній set
) чи тільки для запису (відсутній get
).
У класі Rectangle
замість гетерів та сетерів можна додати властивості та у такий спосіб реалізувати інкапсуляцію.
Крім того, обчислення площі можна реалізувати не як метод, а як властивість тільки для читання:
public class Rectangle { private double width = 10; private double height = 20; public double Width { get { return width; } set { width = value; } } public double Height { get { return height; } set { height = value; } } public double Area { get { return width * height; } } }
Тепер можна спростити роботу з об'єктом класу:
Rectangle rectangle = new(); rectangle.Width = 20; rectangle.Height = 30; Console.WriteLine(rectangle.Width + " " + rectangle.Height); Console.WriteLine(rectangle.Area);
Традиційно імена властивостей починаються з великої літери.
У новітніх версіях C# (починаючи з 7.0) синтаксис лямбда-виразів можна застосовувати всередині опису властивостей, якщо відповідний програмний блок можна реалізувати як один вираз. Наприклад:
public class Rectangle { private double width = 10; private double height = 20; public double Width { get => width; set => width = value; } public double Height { get => height; set => height = value; } public double Area { get => width * height; } }
Починаючи з версії C# 3.0 (2007 рік) до синтаксису мови додані так звані автоматичні властивості:
public int X { get; set; }
Кожній такій властивості відповідає автоматично створене закрите поле, яке є невидимим і до нього немає доступу ані зсередини класу, ані ззовні. Доступ може бути здійснений через властивість. Під час створення об'єкта полям, пов'язаним з автоматичними властивостями, присвоюються усталені значення для відповідних типів. Версія C# 6.0 також дозволяє ініціалізувати автоматичні властивості.
Оскільки в нашому випадку ніякий додатковий код для властивостей
не потрібен, можна створити автоматичні властивості Width
і Height
. Тепер явні поля width
і height
нам
не потрібні, оскільки автоматично створюються інші приховані поля. У розрахунку площі імена полів теж слід прибрати
й замінити властивостями:
public class Rectangle { public double Width { get; set; } = 10; public double Height { get; set; } = 20; public double Area { get => Width * Height; } }
Код програми, яка використовує властивості, не змінився.
Можна вказати такі переваги використання автоматичних властивостей у порівнянні з відкритими полями:
- за необхідності можна змінити код класу та застосувати звичайні властивості з необхідним контролем коректності коду без зміни коду, який використовує клас і його властивості;
- можна окремо визначити директиви для запису й читання.
Наприклад, властивість, опис якої наведено нижче, може бути прочитана з будь-якої частини програми, але модифікована тільки всередині класу:
public int X { get; private set; }
Аналогічна можливість додана також для звичайних (неавтоматичних) властивостей, наприклад:
private int x; public int X { get { return x; } protected set { x = value; } }
2.2.3 Ініціалізація об'єктів
Екземпляр класу створюється шляхом застосування операції new
до конструктора:
Rectangle rectangle = new Rectangle();
Об'єкт створюється в динамічній пам'яті. У цьому прикладі rectangle
– ім'я посилання на об'єкт.
Ім'я Rectangle
після new
– це ім'я конструктора, яке повинне збігатися з ім'ям класу.
Конструктор являє собою метод, який здійснює ініціалізацію об'єкта. У класі може бути визначено декілька конструкторів. Якщо жоден конструктор явно не визначений, автоматично створюється усталений конструктор (без параметрів). Такий конструктор ініціалізує усі поля усталеними початковими значеннями.
Конструктор або декілька конструкторів можна визначити явно. Не можна вказувати тип результату конструктора. Якщо в класу визначено більше одного конструктора, вони повинні відрізнятися списками параметрів. Після того, як визначено хоча б один явинй конструктор, усталений конструктор автоматично не створюється.
Один конструктор можна викликати з іншого з використанням слова this
і конструкції, аналогічної
списку ініціалізації в C++. Крім того, використання this
дозволяє уникнути конфліктів імен:
public class Rectangle { double width; double height; public Rectangle(double width, double height) { this.width = width; this.height = height; } public Rectangle() : this(10, 20) // виклик іншого конструктора { } // ... }
У випадку використання властивостей конфлікту імен зазвичай не виникає. Крім того, можна, наприклад, додати конструктор з одним параметром:
public class Rectangle { public double Width { get; set; } public double Height { get; set; } public Rectangle(double width, double height) { Width = width; Height = height; } public Rectangle(double width) { Width = width; } public Rectangle() : this(10, 20) { } // ... }
Для визначення конструкторів можна застосовувати синтаксис лямбда-виразів. Це доцільно в тому випадку, коли тіло конструктора складається з одного твердження:
public Rectangle(double width) => Width = width;
Окрім ініціалізації за допомогою конструкторів, C# пропонує альтернативний механізм ініціалізації – так звані ініціалізатори.
Традиційно в мовах об'єктно-орієнтованого програмування для ініціалізації об'єктів застосовують конструктори з параметрами. Але іноді таких конструкторів не вистачає і тоді після створення об'єктів значення необхідних властивостей встановлюють вручну. Використовуючи версію 3.0 мови C# (та пізніші версії), можна ініціалізувати відкриті властивості та відкриті поля у спеціальному блоці під час створення об'єкта. Вирази ініціалізації перелічують через кому.
Можна запропонувати два варіанти ініціалізації:
Rectangle rectangle1 = new() // або new Rectangle() { Width = 30, Height = 40 }; Rectangle rectangle2 = new Rectangle { Width = 30, Height = 40 };
У наведених прикладах спочатку викликається конструктор без параметрів, після завершення роботи якого здійснюється запис указаних значень у властивості.
Можна поєднати використання конструкторів з параметрами з ініціалізаторами. Наприклад, можна визначити значення Width
через
конструктор з одним параметром, а Height
через ініціалізатор:
Rectangle rectangle3 = new Rectangle(50) { Height = 60 };
Можна спробувати ініціалізувати об'єкт одночасно конструктором та ініціалізатором, хоч це й не має сенсу:
Rectangle rectangle4 = new Rectangle(50, 60) { Width = 70, Height = 80 };
Оскільки блок ініціалізації виконується після виконання тіла конструктора, значення, визначені у блоці, перекривають значення, присвоєні у конструкторі.
Іноді виникає потреба у вкладених ініціалізаторах. Припустимо, ми створили клас для представлення точки в Декартовій координатній системі:
public class Point { public double X { get; set; } public double Y { get; set; } public Point() { } public Point(double x, double y) { X = x; Y = y; } }
Припустимо, клас Triangle
(трикутник) містить опис властивостей типу Point
:
public class Triangle { public Point A { get; set; } = new(); public Point B { get; set; } = new(); public Point C { get; set; } = new(); }
Тоді можна викликати конструктори всередині блоку ініціалізації:
Triangle triangle = new() { A = new Point(0, 0), B = new Point(0, 4), C = new Point(3, 0) };
Можна також запропонувати таку вкладену ініціалізацію:
Triangle triangle = new() { A = new() { X = 0, Y = 0 }, B = new() { X = 0, Y = 4 }, C = new() { X = 3, Y = 0 } };
У C# є деструктори. Синтаксис їхнього опису цілком аналогічний C++. Можна також використовувати лямбда-вирази.
class Rectangle { //... ~Rectangle() // деструктор { } }
Деструктор може бути лише один, оскільки він ніколи не має параметрів.
У C# деструктор викликається автоматично збирачем сміття перед ліквідацією об'єкта. У деяких випадках об'єкт може бути не вилучений збирачем сміття ніколи, отже деструктор може бути ніколи не викликаний.
2.2.4 Статичні елементи класу. Константи
Поля, методи та властивості можуть бути оголошені з ключовим словом static
. Звертання до таких
полів і методів може здійснюватися без створення екземпляра класу. Статичні поля можуть бути ініціалізовані під
час опису.
Наприклад, до визначення класу Rectangle
можна
додати статичне поле count
, в якому зберігатиметься кількість об'єктів типу Rectangle
,
які були створені в різних місцях програми. У конструкторі значення цього поля збільшується на одиницю. Для того,
щоб унеможливити випадкове спотворення значення лічильника, поле слід визначити як приватне та додати відповідний
метод доступу:
public class Rectangle { private static int count = 0; public static int GetCount() { return count; } public Rectangle() => count++; public double Width { get; set; } public double Height { get; set; } }
Примітка: далі властивість Area
буде опущена для скорочення коду класу Rectangle
.
Скористатися лічильником можна так:
// Поки не було жодного об'єкта Rectangle Console.WriteLine(Rectangle.GetCount()); // 0 Rectangle rectangle = new(); Console.WriteLine(Rectangle.GetCount()); // 1
До класу можна додати деструктор, в якому кількість об'єктів буде зменшуватися:
~Rectangle() => count--;
Але, як зазначалося раніше, виклик деструкторів не гарантований. Скоріш за все, під час роботи невеличкої програми збирач сміття взагалі не працюватиме і деструктор не викликатиметься.
У С# існують статичні властивості. Замість методу доступу можна створити властивість (тільки для читання):
public static int Count { get => count; }
Властивість можна зробити автоматичною з можливістю запису тільки всередині класу. Ця властивість використовуватиметься скрізь замість поля:
public class Rectangle { public static int Count { get; private set; } public Rectangle() => Count++; ~Rectangle() => Count--; public double Width { get; set; } public double Height { get; set; } }
Використання лічильника аналогічне:
// Поки не було жодного об'єкта Rectangle Console.WriteLine(Rectangle.Count); // 0 Rectangle rectangle = new(); Console.WriteLine(Rectangle.Count); // 1
Статичні методи й властивості не можуть використовувати посилання this
.
C# дозволяє звертання до статичних елементів тільки через ім'я класу. На відміну від C++, де можна створити об'єкт або вказівник для спрощення роботи зі статичними елементами, у C# звернення до статичних елементів через ім'я посилання на об'єкт приводить до помилки компіляції:
Rectangle r; Console.WriteLine(r.Count); // помилка компіляції
Для ініціалізації статичних даних у C# використовуються статичні конструктори. Статичний конструктор повинен
бути описаний за допомогою ключового слова static
, для нього не можна визначати специфікатор
доступу. Крім того, з тіла статичного конструктора не можна звертатися до нестатичних елементів класу.
Статичний конструктор завжди викликається неявно – до створення екземпляра чи класу використання будь-якого статичного елемента класу. Наприклад, ініціалізацію лічильника можна здійснити в статичному конструкторі:
public class Rectangle { public static int Count { get; private set; } static Rectangle() => Count = 0; // ... }
У класі можуть бути одночасно статичний і нестатичний конструктори з однаковим набором параметрів.
Починаючи з версії 2.0, мова C# підтримує так звані статичні класи. Такі класи можуть містити тільки статичні елементи.
public static class Static { private static int k = 144; public static int GetK() { return k; } }
Статичні класи не можна використовувати для створення об'єктів, вони не можуть бути застосовані як базові. Статичний клас може мати статичний конструктор.
Як елементи класу можуть виступати константи. Їх описують за допомогою модифікатора const
.
Такі константи створюються компілятором і не можуть бути ніде змінені. Константи є усталено
статичними:
public class PhysicsConstants { public const double Gamma = 6.67430E-11; // Гравітаційна стала public const double SpeedOfLight = 299792458; // Швидкість світла //можуть бути інші елементи }
Використання констант аналогічне роботі з іншими статичними елементами, наприклад:
Console.WriteLine(PhysicsConstants.Gamma);
Існує варіант нестатичних констант – поля з модифікатором readonly
. Значення таких полів
можуть задаватися тільки в конструкторі – це так звані константи екземпляра, що можуть бути різними для
різних екземплярів класу:
public class PhysicsConstants { public const double Gamma = 6.67430E-11; // Гравітаційна стала public const double SpeedOfLight = 299792458; // Швидкість світла public readonly double G; // Прискорення вільного падіння public PhysicsConstants(double g) => G = g; //можуть бути інші елементи }
Для різних планет можна визначити своє значення прискорення вільного падіння:
PhysicsConstants EarthConstants = new(9.81); // g на Землі PhysicsConstants MoonConstants = new(1.62); // g на Місяці
Починаючи з версії 6.0 мови C#, можна створювати автоматичні властивості лише для читання:
public int X { get; }
Таким властивостям можна присвоювати значення лише в тілі конструктора (як полям з модифікатором readonly
).
Спроба присвоювати щось в інших методах генерує помилку компіляції.
2.2.5 Представлення класами реальних сутностей. Застосування принципу SRP
Об'єктно-орієнтована декомпозиція передбачає представлення реальних сутностей класами в програмній моделі. Опис таких класів передбачає представлення різних аспектів життєвого циклу та взаємодії з іншими об'єктами. Але дуже часто класи, які представляють конкретну сутність реального світу виявляються дуже великими, оскільки намагаються об'єднати в собі всі обов'язки сутності та всі аспекти взаємодії з зовнішнім середовищем.
Принцип єдиної відповідальності – це один з п'яти принципів SOLID. Він стверджує, що кожен клас або модуль має відповідати лише за одну задачу або аспект системи й мати лише одну причину для змін. Замість того, щоб об'єднувати в одному класі кілька аспектів поведінки, слід розділити їх на окремі класи. Кожен клас відповідає за один аспект або функцію програми. Дотримання цього принципу має такі переваги:
- Кожен клас або модуль виконує одну конкретну задачу, що робить їх простішими для розуміння, тестування і зміни.
- Якщо потрібно змінити логіку певної функціональності, зміни відбудуться в одному місці, що знижує ймовірність помилок в інших частинах програми.
Практичне застосування принципу єдиної відповідальності розглянуто в прикладі 3.3.
2.3 Визначення методів
Метод – функція, яка описана всередині класу і має безпосередній доступ до інших елементів класу. На відміну від C++, неабстрактні та нечасткові методи завжди реалізуються всередині визначення класу.
Щоб викликати публічний статичний метод іншого класу, слід використовувати такий синтаксис:
Class_name.Method_name(actual_parameters)
У такій формі викликають стандартні математичні функції. Наприклад
y = Math.Sin(x);
Починаючи з C# 6.0, можна імпортувати статичні елементи з певного класу, наприклад:
using static System.Math;
Тепер можна викликати статичні методи, визначені в класі Math
, не використовуючи префіксу Math
:
y = Sin(x);
Функція може бути без параметрів:
static int Zero() { return 0; }
Викликаючи таку функцію, також необхідно використовувати дужки:
Console.WriteLine(Zero());
Інструкція return
у тілі функції забезпечує завершення роботи функції. Значення виразу
після return
стає значенням функції, яке ця функція повертає.
Функція може не повертати ніякого результату. Для позначення цього використовується тип
void
.
static void Hello() { Console.WriteLine("Hello!"); }
У цьому випадку в тілі функції return
може бути відсутнім. Якщо інструкція
return
присутня, то після неї не повинно бути ні якого виразу. Таку функцію можна викликати
тільки окремою інструкцією.
Hello();
Функцію можна викликати з тіла цієї ж функції. Рекурсія – це виклик функції з неї самої безпосередньо або опосередковано.
Усталено параметри передаються до функцій за значенням, тобто значення фактичних параметрів копіюються в
пам'ять, відведену для формальних параметрів. Додатковою можливістю C# є передача параметрів типів-значень за
посиланням. Існує два способи опису таких параметрів. Перший спосіб припускає, що до виклику методу передані
змінні були проініціалізовані. Для передачі параметрів за посиланням використовується ключове слово
ref
.
public static void Swap(ref int a, ref int b) { int c = a; a = b; b = c; }
Під час виклику такого методу і передачі йому фактичних параметрів також варто використовувати ключове слово
ref
. Фактичні параметри повинні бути обов'язково ініціалізовані. Наведемо повний приклад
використання функції Swap()
:
using System; namespace SwapperApp { class Swapper { public static void Swap(ref int a, ref int b) { int c = a; a = b; b = c; } static void Main(string[] args) { int x = 1; int y = 10; Swap(ref x, ref y); Console.WriteLine("x = " + x + " y = " + y); } } }
Як видно з приклада, цілі (та інші вбудовані) типи можна зшивати з рядками за допомогою операції додавання. При цьому дані автоматично перетворюються в строкове подання.
Починаючи з C# 7.0, також можна використовувати модифікатор ref
перед функцією
повернення типу. У цьому випадку також треба додати модифікатор ref
під час
виклику функції:
static ref int SecondName(ref int n) { return ref n; } static void Main(string[] args) { int k = 3; ref int k2 = ref SecondName(ref k); k2 = 4; Console.WriteLine(k); }
Інший спосіб використовується в тих випадках, коли функція повинна повернути більш ніж одне значення. Для
вказівки передачі параметрів за посиланням використовується ключове слово out
. Компілятор
стежить за тим, щоб у тілі функції таким параметрам були присвоєні значення.
Наведена нижче функція приймає масив чисел з рухомою крапкою, повертає мінімальне значення, а також через параметр
з атрибутом out
повертає індекс елемента з мінімальним значенням:
static double? Min(double[] arr, out int index) { if (arr == null || arr.Length< 1) { index = -1; return null; } index = 0; for (int i = 1; i < arr.Length; i++) { if (arr[i] < arr[index]) { index = i; } } return arr[index]; }
Під час виклику функції для відповідного фактичного параметра також необхідно вказувати атрибут out
:
double[] a = { 3, 4, 1, 2.5 }; int index; Console.WriteLine(Min(a, out index) + " " + index);
Версія 7.0 C# дозволяє визначити необхідні змінні з модифікаторами out
всередині
виклику методу у списку фактичних аргументів. Після повернення із викликаного методу їх можна використовувати:
double[] a = { 3, 4, 1, 2.5 }; Console.WriteLine(Min(a, out int index) + " " + index);
Властивості не можна передавати в методи як аргументи, якщо вони описані як ref
і out
параметри.
Починаючи з C# 7.2 для передачі параметра за посиланням можна також використовувати модифікатор in
.
Розміщення ключового слова in
перед параметром типу значення дозволяє оголосити
параметр, який передається за посиланням, але його значення не можна змінити; це трактується як константа в тілі
функції. Наприклад:
static double Sum(in double x, in double y) { return x + y; } static void Main(string[] args) { double x = 1, y = 2; Console.WriteLine(Sum(in x, in y)); // модифікатор in є обов'язковим }
Якщо спробувати змінити значення таких параметрів у тілі функції, буде отримано помилку компілятора:
static double Sum(in double x, in double y) { x = 4; // Помилка! // ... }
Використання модифікатора in
підвищує ефективність надсилання великих структур
даних без копіювання.
Примітка: модифікатор in
працює як посилання на константу в C++.
Версія 4.0 мови C# дозволяє описувати функції з усталеними параметрами (default parameters), як і в C++. Такий підхід дозволяє викликати одну функцію з різною кількістю параметрів. Наприклад
static double Sum(double a, double b = 0, double c = 0) { return a + b + c; }
Цю функцію можна викликати або з одним, або з двома, або з трьома параметрами:
double x = 0.1; double y = 0.2; double z = 0.3; Console.WriteLine(Sum(x)); Console.WriteLine(Sum(x, y)); Console.WriteLine(Sum(x, y, z));
З наведеного приклада видно, що якщо значення усталених параметрів задовольняють наші потреби, під час виклику відповідні фактичні параметри можна не вказувати. Усталені параметри задаються в списку останніми, інакше ми отримаємо повідомлення про синтаксичну помилку:
static void F(double x, int y = 0, int h) { } // Синтаксична помилка!
Під час виклику можна вказувати імена формальних параметрів (у версії 4.0 мови C#). Вказується ім'я параметра та після двокрапки – його значення. Наприклад, у наведеному нижче фрагменті програми обчислюється вираз y = ax + b:
static double Y(double a, double x, double b) { return a * x + b; } static void Main(string[] args) { Console.WriteLine(Y(a: 2, x: 3, b: 4)); // 10 }
Ця можливість є особливо корисною в поєднанні з усталеними параметрами. Вона дозволяє вказувати лише необхідні параметри. Наприклад:
static double Y(double a = 1, double x = 0, double b = 0) { return a * x + b; } static void Main(string[] args) { Console.WriteLine(Y(a: 2, x: 3, b: 4)); // 10 Console.WriteLine(Y(x: 5, b:11)); // 16 Console.WriteLine(Y(x: 5)); // 5 Console.WriteLine(Y()); // 0 }
Параметри з іменами повинні бути останніми у списку фактичних параметрів.
Усі нестатичні методи неявно отримують посилання на об'єкт для якого вони використані. Для виклику нестатичного методу необхідно застосувати посилання на раніше створений об'єкт.
2.4 Перевантаження операцій
Під час проєктування класу можна визначити набір операцій, які можна виконувати над об'єктами. До операцій, що перевантажуються, належать:
- унарні операції
+
,-
,!
,~
,++
,--
,true
,false
; - бінарні операції
+
,-
,*
,/
,%
,&
,|
,^
,<<
,>>
,==
,!=
,<
,>
,<=
,>=
.
Якщо для об'єкта перевантажені операції true
і false
, посилання на нього
може бути використане як аргумент умовної операції, іf
і циклів. Операції true
і false
, ==
і !=
, >
і <
, >=
і <=
є
парними: якщо в класі перевантажується одна з них, обов'язково повинна бути перевантажена й інша.
Операції, що перевантажується, відповідає відкритий статичний метод, назва якого складається з ключового слова operator
і
позначення операції.
Функції explicit operator
Ім'я_типу()
і implicit operator
Ім'я_типу()
використовуються
для явних і неявних перетворень типів відповідно. Неявні перетворення в C# відповідають спеціальним операторам перетворення
(випадок перетворення з класу в інший тип) і конструкторам з одним параметром (випадок перетворення з іншого типу
в даний клас).
У наведеному нижче класі перевантажується операція +
і операція неявного приведення до типу string
:
using System; namespace PointTest { class Point { private double x, y; public Point(double x, double y) { this.x = x; this.y = y; } public static Point operator+(Point a, Point b) { return new Point(a.x + b.x, a.y + b.y); } public static implicit operator string(Point p) { return p.x + " " + p.y; } } class Test { static void Main(string[] args) { Point p1 = new Point(1, 2); Point p2 = new Point(3, 4); Point p3 = p1 + p2; Console.WriteLine(p3); // Приведення до string } } }
Відмінністю перевантаження операцій є те, що якщо перевантажена арифметична операція, то автоматично вважається перевантаженою відповідна операція складеного присвоювання.
2.5 Індексатори
Спеціальний вид властивостей, так званий індексатор, дозволяє звертатися до об'єкта класу за допомогою операції
взяття індексу ([]
). Щоб створити індексатор, слід визначити вислідний тип, за яким слідує ключове
слово this
, а потім визначення індексу у квадратних дужках. Інша частина визначення
така ж, як і у визначенні властивості. У наведеному нижче класі індексатор забезпечує доступ до закритого поля:
class HiddenArray { private int[] arr = { 1, 2, 3 }; public int this[int index] { get { return arr[index]; } set { arr[index] = value; } } }
Завдяки індексатору з об'єктом створеного класу можна працювати як з масивом:
HiddenArray hiddenArray = new(); hiddenArray[0] = 4; int k = hiddenArray[1];
Наведений нижче клас описує точку в просторі, а індексатор використовується для доступу по вимірах (1, 2 чи 3):
using System; namespace Point3DApp { class Point3D { double x, y, z; public Point3D(double x, double y, double z) { this.x = x; this.y = y; this.z = z; } public double this [int index] // індексатор { set { switch (index) { case 1: x = value; break; case 2: y = value; break; case 3: z = value; break; } } get { switch (index) { case 1: return x; case 2: return y; case 3: return z; // В іншому випадку - максимальне double default: return Double.MaxValue; } } } } class Test { static void Main(string[] args) { Point3D p3d = new Point3D(2, 3, 4); p3d[3] = 5; // запис властивості for (int i = 1; i <= 4; i++) { Console.WriteLine(p3d[i]); // читання властивості } } } }
Тип індексу не обов'язково повинен бути цілим.
2.6 Безіменні типи та записи
Починаючи з версії C# 3, можна створювати локальні змінні так званих безіменних типів (anonymous types). Наприклад:
var city = new { Name = "Київ", Population = 2884000 }; Console.WriteLine(city.Name);
Безіменний тип – це тип-посилання. Значення, які були визначені під час створення змінної, доступні
лише для читання, їх не можна змінити. До безіменних типів не можна додавати методи, події та інші елементи, окрім
властивостей. Для безіменних типів визначено метод Equals()
, яка перевіряє еквівалентність усіх властивостей.
Для того, щоб передати об'єкт безіменного типу в функцію як аргумент, параметр повинен бути описаний як object
.
var city = new { Name = "Київ", Population = 2884000 }; PrintCity(city); . . . void PrintCity(object city) { Console.WriteLine(city); // { Name = Київ, Population = 2884000 } }
Можливості використання таких параметрів істотно обмежені, оскільки змінну не можна явно перетворити до безіменного типу. Тому бажано використовувати змінні безіменних типів у тому ж блоку, де вони визначені.
Введений у версії C# 9 тип record
(запис) – це спрощений клас, багато в чому
аналогічний безіменним типам. Його також зазвичай створюють для представлення незмінних об'єктів. Наприклад, можна
описати такий запис:
public record City { public string Name { get; init; } public int Population { get; init; } }
Ключове слово init
показує, що автоматичну властивість можна визначати в конструкторі
або в ініціалізаторі, але потім її значення змінювати не можна. Тепер ми маємо тип для створення незмінених об'єктів.
Примітка: ключове слово init
в описі автоматичних властивостей, уведене
у C# 9, можна використовувати не тільки для опису властивостей записів, але й у звичайних класах.
Тепер можна створювати об'єкти запису.
var city = new City() { Name = "Київ", Population = 2884000 };
Можна створювати конструктори записів. Підтримується автоматична перевірка еквівалентності.
2.7 Структури й переліки
Структура в С#, як і клас, може містити поля і методи, але не є типом-посиланням. Структура – це тип-значення.
Структури зручні для представлення невеликих об'єктів. Крім того, структури завжди створюються в програмному стеку,
навіть якщо для їхнього створення використовується операція new
. Розміщення в стеку підвищує
ефективність роботи програми. Структура може містити конструктори. Автоматично створений конструктор без параметрів
ініціалізує усі поля усталеними значеннями для відповідних типів.
public struct Point { public double x, y; public Point(double x, double y) { this.x = x; this.y = y; } }
До версії C# 9 включно в тілі структури не можна було ініціалізувати дані. Не можна також було перекривати усталений конструктор. Версія C# 10 дозволяє визначати конструктор без параметрів, який забезпечує ініціалізацію полів необхідними значеннями.
Змінним-структурам не можна присвоювати значення null
(якщо вони не null
-сумісні).
Структури не беруть участь у збиранні сміття.
Якщо структура створюється за допомогою new
, її поля ініціалізуються усталеними значеннями.
В іншому разі поля не проініціалізовані і їх не можна використовувати без присвоювання необхідних значень:
Point p1 = new Point(); double d1 = p1.x; // d1 == 0 Point p2; double d2 = p2.y; // помилка компіляції
Під час ініціалізації й присвоювання структури копіюються цілком:
Point p1 = new Point(); Point p2 = p1; // повне копіювання
Структури передаються як параметри методів за значенням. Для того, щоб передавати структури за посиланням, потрібно
використовувати модифікатор ref
або out
.
Оскільки структури є типами-значеннями, під час їхнього використання там, де очікується посилання на об'єкт класу
(наприклад, у присвоюванні їх об'єкту типу System.Object
) відбувається їхнє так зване "упакування" в
тип-посилання. При цьому виділяється пам'ять у динамічній області, в яку копіюються всі дані структури й повертається
посилання на цю область. Упакування створює копію даних структури. Під час зворотного перетворення відбувається "розпакування" – дані
копіюються в екземпляр структури:
Point p = new Point(1,2); object ob = p; // упакування, створення копії Point p2 = (Point) ob; // розпакування
Починаючи з версії C# 10 об'єкти-записи (record) також можна розташовувати в стеку. Для опису таких записів використовують
пару ключових слів record struct
.
Як і в С++, перелік визначає тип, що представляє набір іменованих констант. Наприклад:
enum Digits { zero, one, two, three } // zero == 0, one == 1, two == 2, three == 3
Першому елементу присвоюється значення усталене 0, а кожному наступному – на одиницю більше, ніж значення попереднього. Якщо це необхідно, значення можуть задаватися явно. Значення тих елементів, для яких значення не було задано, буде на одиницю більше попереднього:
enum Digits { one = 1, two, three } // two == 2, three == 3
Для завдання значень переліку можна використовувати інші елементи того ж переліку:
enum Digits { one = 1, two, min = one, max = two }
Для звертання до елементів, визначених у переліку, задається ім'я переліку і через крапку відповідна константа.
Наприклад: Digits.two
. Можна визначати змінні й константи типу переліку:
Digits d = Digits.one;
Для перетворення переліку в ціле число і назад необхідне приведення типу:
int c = (int) Digits.two; // c == 2
Усталений тип констант, які можна зберігати в переліку – int
. Але цей тип (базовий тип
переліку) можна змінити. Наприклад:
enum Digits : byte { one = 1, two, three } // two == 2, three == 3
Для роботи з переліками є ряд корисних статичних методів System.Enum
. Зокрема, метод GetUnderlyingType()
повертає
тип даних, що використовується для зберігання значень переліку, GetValues()
повертає масив значень.
Наприклад:
enum Digits : byte { one = 1, two, three } class Program { static void Main(string[] args) { Console.WriteLine(Enum.GetUnderlyingType(typeof(Digits))); foreach (var v in Enum.GetValues(typeof(Digits))) { Console.Write(v + " "); // one two three } } }
У наведеному прикладі операція typeof
повертає об'єкт типу System.Type
. Для нього
визначено метод ToString()
, яка дозволяє отримати ім'я типу.
2.8 Кортежі
Починаючи з C# 7.0 з'явилася можливість легкого створення так званих кортежів. Кортеж (tuple) – це скінченна впорядкована послідовність елементів. На відміну від класів та структур, кортежі підтримують так званий "легкий" синтаксис: їх можна використовувати як групи упорядкованих даних. У найпростішому випадку можна створити безіменний кортеж у такий спосіб:
var unnamedPair = (1, 2);
Окремі елементи кортежу можна отримати, використовуючи зарезервовані імена Item1
, Item2
тощо.
Console.WriteLine(unnamedPair.Item1); Console.WriteLine(unnamedPair.Item2);
Більш адекватний підхід передбачає використання іменованих кортежів:
var namedPair = (First: 1, Second: 2); Console.WriteLine(namedPair.First); Console.WriteLine(namedPair.Second);
Типи елементів кортежу отримуються з початкових значень. Можна явно визначити їх під час створення змінних:
(int, int) unnamedPair = (1, 2); (int First, int Second) namedPair = (First: 1, Second: 2);
Можна використовувати значення змінних для ініціалізації кортежів:
var integer = 1; var real = 1.5; var tuple = (First: integer, Second: real); Console.WriteLine($"{tuple.First} {tuple.Second}");
Кортежі можуть використовуватися як аргументи функцій:
static int Sum((int, int) args) { return args.Item1 + args.Item2; } static void Main(string[] args) { Console.WriteLine(Sum((1, 2))); }
Однією з найважливіших переваг кортежів є можливість повернення декількох значень із функції. Наведений нижче приклад демонструє використання кортежів для повернення двох значень з функції. Ця функція розв'язує лінійне рівняння. Перше значення є кількістю коренів (-1, якщо коренів безмежна кількість). Друге значення є коренем рівняння:
static (int rootsCount, double? root) SolveLinear(double a, double b) { if (a != 0) { return (1, -b / a); } if (b != 0) { return (0, null); } return (-1, null); } static void Main(string[] args) { Console.WriteLine(SolveLinear(1, -2)); // (1, 2) Console.WriteLine(SolveLinear(0, -2)); // (0, ) Console.WriteLine(SolveLinear(0, 0)); // (-1, ) }
2.9 Методи, які розширюють наявні типи
Іноді виникає потреба в додаванні до раніше створених класів нових методів. Традиційно є три шляхи розв'язання цієї проблеми:
- модифікація вихідного коду. Звичайно, такий підхід не можна вважати коректним. Крім того, іноді модифікація вихідного коду взагалі неможлива, наприклад, коли йдеться про стандартні класи та структури .NET, або взагалі, коли ми використовуємо класи, які надані у скомпільованому вигляді;
- створення похідного класу, до якого додаються необхідні методи. Цей підхід має чисельні обмеження. Наприклад,
перевантажені операції не можуть бути застосовані до об'єктів похідних класів, отже відповідні операторні функції
необхідно визначати знову. Крім того, похідні класи не є частиною бібліотеки класів .NET і для їх імен не можна
створити ключові слова C#, як, наприклад,
string
. Багато стандартних класів оголошені як sealed, що не дозволяє створювати похідні класи. Але найважливішим є те, що структури не підтримують механізму успадкування; - створення власних статичних функцій з параметром типу об'єкта класу, який ми хочемо розширити. Це цілком коректний підхід, але він пов'язаний з деякими незручностями. Зокрема, поза класом, у якому визначені ці функції, необхідно вживати відповідний префікс.
Версія 3.0 мови C# надає можливість додавання нових методів до наявних класів та структур. Для додавання нового
метода (наприклад, з ім'ям newMethod
) необхідно виконати такі дії:
- створити статичний клас
- додати нову статичну функцію
newMethod
, першим параметром якої буде посилання на об'єкт типу (класу або структури), до якого ми хочемо додати новий метод; перед описом параметра слід додати модифікаторthis
.
Тепер всередині поточного простору імен можна вживати функцію як нестатичний метод об'єкта відповідного типу. Наприклад,
можна розширити стандартний тип int
функцією, яка потроює відповідне ціле значення.
Створюємо новий статичний клас:
namespace Extensions { public static class IntExtensions { public static int Triple(this int k) { return k * 3; } } }
Можна викликати метод Triple()
як для змінних, так і для констант відповідного типу:
int n = 2; int m = n.Triple(); int k = 9.Triple();
Функцію Triple()
можна також викликати як статичну:
int q = IntExtensions.Triple(m);
Видимість методу Triple()
обмежена поточним простором імен. За допомогою директиви using
розширення
можна зробити приступними в інших просторах імен.
Можна створювати методи, які розширюють наявні класи, з декількома аргументами. До відповідного статичного методу додаються другий, третій і т.д. аргументи, які під час виклику через об'єкт розширюваного класу відповідають першому, другому і т.д. фактичним аргументам. Наприклад:
public static class DoubleExt { public static double Add(this double a, double b) { return a + b; } } class Program { static void Main(string[] args) { double x = 2.5.Add(3); Console.WriteLine(x); // 5.5 } }
2.10 Вкладені типи
Як і в багатьох інших мовах об'єктно-орієнтованого програмування, у С# можна створювати вкладені типи (типи, які описані всередині інших типів). Найчастіше це вкладені класи. Вкладені типи не мають доступу до нестатичних елементів зовнішніх типів.
Для вкладених типів, як і для інших елементів, можна застосовувати директиви видимості. Відкриті класи можна використовувати поза зовнішнім класом, закриті – тільки всередині. З зовнішнього класу немає доступу до закритих елементів вкладених класів.
public class Outer { static int i = 12; public class FirstInner { public void F() { i = 10; } } private class SecondInner { public int z; private int x; } SecondInner si = new SecondInner(); public void g() { si.z = 12; si.x = 13; // Помилка! } } class Program { static void Main(string[] args) { Outer.FirstInner first = new Outer.FirstInner(); first.F(); Outer.SecondInner s; // Помилка! } }
На відміну від інших мов, не можна створювати локальних (всередині функцій чи блоків) класів. Не можна створювати безіменні класи, які розширюють інші, або реалізують інтерфейси.
Вкладеними також можуть бути структури та переліки. Можна вкладати типи в структури. Наприклад:
struct Polyline { public struct Point { public double X, Y; } public Point[] Points { get; set; } }
В інтерфейси також можна вкладати інші типи.
3 Приклади програм
3.1 Лінійне рівняння
Припустимо, необхідно спроєктувати клас, що представляє лінійне рівняння. Поля цього класу – коефіцієнти a і b, а також корінь x, який необхідно знайти. Можна запропонувати таку програму:
using System; namespace LinearEquation { public class LinearEquation { public double A { get; set; } public double B { get; set; } public double? X { get; private set; } public LinearEquation() { A = B = 0; X = null; } public int Solve() { if (A == 0) { if (B == 0) { return -1; // безмежна кількість розв'язків } else { return 0; // немає розв'язків } } X = -B / A; return 1; // один розв'язок } } class Program { static void Main(string[] args) { LinearEquation e = new LinearEquation(); e.A = double.Parse(Console.ReadLine() ?? "0"); e.B = double.Parse(Console.ReadLine() ?? "0"); switch (e.Solve()) { case -1: Console.WriteLine("Безмежна кількість розв'язкiв"); break; case 0: Console.WriteLine("Немає розв'язкiв"); break; case 1: Console.WriteLine("X = " + e.X); break; } } } }
3.2 Розв'язання рівняння з двома коренями
Припустимо, нам необхідно розв'язати таке рівняння:
Можна запропонувати три варіанти
Перший варіант
Можна створити функцію, яка повертає false
, якщо b < 0 і рівняння не може
бути розв'язане. Якщо корені можна знайти, функція повертатиме true
. Оскільки коренів – два,
доцільно використати параметри с атрибутами out
:
class Program { static bool SolveEquation(double b, out double? x1, out double? x2) { if (b < 0) { x1 = x2 = null; return false; } x1 = b; x2 = -b; return true; } static void Main(string[] args) { double? x1, x2; double[] bValues = { 3, -3 }; foreach (double b in bValues) { if (SolveEquation(b, out x1, out x2)) { Console.WriteLine("x1 = " + x1 + " x2 = " + x2); } else { Console.WriteLine("No solution"); } } } }
Як видно з коду, корені визначені як null
-сумісні, оскільки у випадку, коли рівняння не має
розв'язків, значення параметрів після виклику функції не можуть залишитися невизначеними, а будь-яке інше значення
потенційно може сприйматись як справжній корінь рівняння.
Другий варіант
Другий варіант передбачає створення спеціальної структури для представлення результатів:
class Program { struct Result { public bool Solved { get; set; } public double? X1 { get; set; } public double? X2 { get; set; } } static Result SolveEquation(double b) { Result result = new(); if (b < 0) { result.X1 = null; result.X2 = null; result.Solved = false; } else { result.X1 = b; result.X2 = -b; result.Solved = true; } return result; } static void Main(string[] args) { double[] bValues = { 3, -3 }; foreach (double b in bValues) { var result = SolveEquation(b); if (result.Solved) { Console.WriteLine("x1 = " + result.X1 + " x2 = " + result.X2); } else { Console.WriteLine("No solution"); } } } }
Третій варіант
Третій варіант реалізації – це функція, яка повертає кортеж. Цей варіант схожий на використання структури:
class Program { static (bool solved, double? x1, double? x2) SolveEquation(double b) { if (b < 0) { return (false, null, null); } return (true, b, -b); } static void Main(string[] args) { double[] bValues = { 3, -3 }; foreach (double b in bValues) { var result = SolveEquation(b); if (result.solved) { Console.WriteLine("x1 = " + result.x1 + " x2 = " + result.x2); } else { Console.WriteLine("No solution"); } } } }
3.3 Обробка даних про книги на книжковій полиці
У попередній лабораторній роботі було розглянуто приклад програми для роботи з масивом назв книжок. Зазвичай інформація про книжку не обмежується її назвою. Важлива інформація – автор або автори, рік видання, видавництво. Можна також додати кількість сторінок, формат тощо. Якщо всю цю інформацію спробувати записати в одному рядку (string), ми отримаємо довгі рядки, які погано сприймаються та їх важко обробляти. Для роботи з такими об'єктами реального світу в різних мовах застосовують структури, записи, та інші засоби групування даних. У C# для представлення книжки доцільно створити клас.
Крім того, книжки можуть бути розташовані в бібліотеках, на полицях, у шафах тощо. Можна запропонувати класи для представлення таких сутностей:
- книжкова полиця
- книга
- автор
Необхідно реалізувати функції для ініціалізації даних, пошуку книжок, у назви яких входить визначена послідовність символів, сортування книжок за алфавітом назв з ігноруванням регістру та виведення консоль результатів.
Перше наближення об'єктної моделі полягає у створенні трьох класів для трьох сутностей відповідно. Але слід зауважити, що при цьому кожен клас відповідатиме не тільки за зберігання відповідних даних, а також за отримання представлення у вигляді рядка для виведення даних у консольне вікно. Крім того, клас для представлення книжкової полиці відповідатиме за пошук та сортування. Такий підхід порушує принцип єдиної відповідальності (SRP).
Для того, щоб якось покращити ситуацію, можна створити окремі класи для пошуку, друку тощо. Можна згрупувати методи для отримання представлення рядком різних даних та визначити їх як статичні. Отримаємо такий набір класів:
/// <summary> /// Простір імен, який охоплює класи для представлення книжкової полиці /// </summary> namespace Bookshelf { /// <summary> /// Представляє автора книги на книжковій полиці /// </summary> public class Author { public string Name { get; set; } = ""; public string Surname { get; set; } = ""; public Author() { } public Author(string name, string surname) { Name = name; Surname = surname; } /// <summary> /// Надає представлення рядком даних про автора /// </summary> /// <param name="author">автор книги</param> /// <returns>рядок, який представляє автора книги</returns> public static implicit operator string(Author author) { return StringRepresentations.ToString(author); } } /// <summary> /// Представляє книгу на книжковій полиці /// </summary> public class Book { public string Title { get; set; } = ""; public int Year { get; set; } public Author[] Authors { get; set; } = { }; public Book() { } public Book(string title, int year) { Title = title; Year = year; } /// <summary> /// Надає представлення рядком даних про книжку /// </summary> /// <param name="book">книга</param> /// <returns>рядок, який представляє дані про книгу</returns> public static implicit operator string(Book book) { return StringRepresentations.ToString(book); } } /// <summary> /// Книжкова полиця /// </summary> public class Bookshelf { public Book[] Books { get; set; } = { }; /// <summary> /// Індексатор, який дозволяє отримувати книгу за індексом /// </summary> /// <param name="index">індекс книги</param> /// <returns>книга з відповідним індексом</returns> public Book this[int index] { get => Books[index]; set => Books[index] = value; } /// <summary> /// Конструктор /// </summary> /// <param name="books">відкритий масив книжок</param> public Bookshelf(params Book[] books) { Books = books; } /// <summary> /// Надає представлення рядком даних про книжкову полицю /// </summary> /// <param name="bookshelf">книжкова полиця</param> /// <returns>рядок, який представляє дані про книжкову полицю</returns> public static implicit operator string(Bookshelf bookshelf) { return StringRepresentations.ToString(bookshelf); } } /// <summary> /// Статичний клас, який дозволяє отримувати представлення /// у вигляді рядків різних об'єктів застосунку /// </summary> public static class StringRepresentations { /// <summary> /// Надає представлення рядком даних про автора /// </summary> /// <param name="author">автор книги</param> /// <returns>рядок, який представляє автора книги</returns> public static string ToString(Author author) { return author.Name + " " + author.Surname; ; } /// <summary> /// Надає представлення рядком даних про книжку /// </summary> /// <param name="book">книга</param> /// <returns>рядок, який представляє дані про книгу</returns> public static string ToString(Book book) { if (book == null) { return ""; } string result = ""; result += string.Format("Книга. Назва: \"{0}\". Рiк видання: {1}", book.Title, book.Year); result += " Автор(и):\n"; for (int i = 0; i < book.Authors.Length; i++) { result += string.Format(" {0}", (string)book.Authors[i]); result += (i < book.Authors.Length - 1 ? "," : "") + "\n"; } return result; } /// <summary> /// Надає представлення рядком даних про книжкову полицю /// </summary> /// <param name="bookshelf">книжкова полиця</param> /// <returns>рядок, який представляє дані про книжкову полицю</returns> public static string ToString(Bookshelf bookshelf) { string result = ""; foreach (Book book in bookshelf.Books) { result += book + "\n"; } return result; } } /// <summary> /// Надає методи для пошуку і сортування книжок на полиці /// </summary> public static class BookHandle { /// <summary> /// Шукає визначену послідовність символів у назвах книжок /// </summary> /// <param name="bookshelf">книжкова полиця</param> /// <param name="characters">послідовність символів, яку треба відшукати</param> /// <returns>масив книжок, у назви яких входить визначена послідовність</returns> public static Book[] ContainsCharacters(Bookshelf bookshelf, string characters) { return Array.FindAll(bookshelf.Books, book => book.Title.Contains(characters)); } /// <summary> /// Здійснює сортування книжок за алфавітом назв без урахування регістру /// </summary> /// <param name="bookshelf">книжкова полиця</param> public static void SortByTitles(Bookshelf bookshelf) { Array.Sort(bookshelf.Books, (b1, b2) => string.Compare(b1.Title.ToUpper(), b2.Title.ToUpper())); } } }
У класі Program
можна визначити окремі методи для підготовки необхідних даних і здійснення пошуку
та сортування даних:
namespace Bookshelf; /// <summary> /// Консольний застосунок для демонстрації роботи з книжками на книжковій полиці /// </summary> class Program { /// <summary> /// Готує тестові дані для демонстрації роботи з книжками на книжковій полиці /// </summary> /// <returns>Книжкова полиця з доданими книжками</returns> public static Bookshelf CreateBookshelf() { return new Bookshelf( new Book(@"The UML User Guide", 1999) { Authors = new[] { new Author("Grady", "Booch"), new Author("James", "Rumbaugh"), new Author("Ivar", "Jacobson") } }, new Book(@"Об'єктно-орієнтоване моделювання програмних систем", 2007) { Authors = new[] { new Author("Iгор", "Дудзяний") } }, new Book(@"Thinking in Java", 2005) { Authors = new[] { new Author("Bruce", "Eckel") } }, new Book(@"Програмування мовою С# 7.0: навчальний посібник", 2017) { Authors = new[] { new Author("Ігор", "Коноваленко"), new Author("Павло", "Марущак"), new Author("Володимир", "Савків") } }, new Book(@"C# 9.0 in a Nutshell: The Definitive Reference", 2021) { Authors = new[] { new Author("Joseph", "Albahari") } } ); } /// <summary> /// Демонструє роботу функцій пошуку та сортування книжок /// </summary> /// <param name="bookshelf">книжкова полиця, для якої здійснюється демонстрація роботи</param> public static void HandleBookshelf(Bookshelf bookshelf) { Console.WriteLine("\nПочатковий стан:"); Console.WriteLine(bookshelf); Console.WriteLine("\nНазви, які містять \"The\""); var result = BookHandle.ContainsCharacters(bookshelf, "The"); foreach (var book in result) { Console.WriteLine(book.Title); } //Console.WriteLine(result.ToArray().Length > 0 ? string.Join("\n", result) : "No"); Console.WriteLine("\nЗа алфавітом без урахування регістру:"); BookHandle.SortByTitles(bookshelf); Console.WriteLine(bookshelf); } /// <summary> /// Стартова точка консольного застосунку /// </summary> static void Main() { Console.OutputEncoding = System.Text.Encoding.UTF8; HandleBookshelf(CreateBookshelf()); } }
4 Вправи для контролю
- Створити класи з конструкторами та властивостями для опису студента та його оцінок.
- Створити клас з конструктором для опису товару (зберігаються назва та ціна).
- Створити клас з конструктором для опису користувача (зберігаються ім'я та пароль).
- Створити функцію з двома параметрами-посиланнями, яка збільшує один параметр на 1 та зменшує другий на 2.
- Створити клас для опису паралелепіпеда. Забезпечити звертання до довжини, ширини та висоти як через імена властивостей, так і через індексатор.
- Розширити тип
int
методом обчислення квадрату. - Розширити тип
double
методом обчислення третього степеня. - Розширити тип
int
методом обчислення факторіала. - Розширити тип
double
методом обчислення цілого степеню. - Розширити клас
System.String
, додавши функцію видалення першої та останньої літери. - Розширити клас
System.String
, додавши функцію перевірки, чи збігаються перша та остання літери. - Створити клас "Група людей". Реалізувати перевантаження операцій
+
та-
для додавання та видалення учасників групи. Використовувати ініціалізатори об'єктів. - Створити клас "Простий дріб". Реалізувати перевантаження операцій
+
, - та*
. - Створити клас "Група" зі вкладеною структурою для опису студента та масивом студентів.
5 Контрольні запитання
- Що таке простори імен C# і з якою метою їх створюють?
- Як здійснюється опис та підключення просторів імен?
- З яких основних елементів складається опис класу?
- У чому полягає зміст інкапсуляції?
- Які рівні доступу до елементів класу (директиви видимості) підтримує C#?
- Як визначити доступ до елементів усередині складання (assembly)?
- Як можна використовувати посилання
this
? - Поняття властивостей. У чому полягають особливості використання властивостей?
- Як створити властивості тільки для запису і тільки для читання?
- У чому переваги та недоліки автоматичних властивостей?
- Як здійснюється використання конструкторів з інших конструкторів?
- У чому переваги та недоліки ініціалізаторів об'єктів у порівнянні з конструкторами?
- Деструктори у C#. Коли викликаються деструктори?
- Чим відрізняються статичні та нестатичні елементи класу?
- Що таке статичний конструктор і коли він викликається?
- Використання
readonly
. Де можна ініціалізувати елементиreadonly
? - Як здійснюється опис статичного класу?
- У чому є відмінності передачі параметрів за допомогою
ref
таout
? - Для чого здійснюється перевантаження операцій?
- Як здійснюється опис операторної функції?
- Опис індексаторів. Чим відрізняється використання індексаторів від роботи з масивами?
- У чому сенс використання безіменних типів?
- У чому особливості створення і використання записів?
- У чому переваги та недоліки структур?
- У чому полягають особливості створення об'єкта-структури за допомогою
new
? - Як можна описати та використовувати переліки?
- Синтаксис та переваги застосування кортежів.
- Як здійснюється опис методу, який додається до наявного класу?