Лабораторна робота 2
Успадкування та поліморфізм
1 Завдання на лабораторну роботу
1.1 Індивідуальне завдання
Розширити програму, яка була створена у попередній лабораторній роботі, ієрархією сутностей відповідно до наведеної таблиці:
Базовий клас | Похідний клас |
---|---|
Група людей | Академічна група |
Заклад | Інститут |
Структурна одиниця | Факультет |
Форма контролю знань | Сесія |
Населений пункт | Місто |
Регіон | Область |
Об'єднання | Спортивна секція |
Спортивний клуб | Футбольний клуб |
Творчій колектив | Музичний гурт |
Творчій колектив | Музичний гурт |
Збірка музичних творів | Альбом |
Житло | Квартира |
Книга | Збірка оповідань |
Діяч культури | Художник |
Діяч культури | Письменник |
Відтворити завдання попередньої лабораторної роботи. Крім того, реалізувати операції додавання об'єктів у групу, (+) та видалення об'єктів з групи ( – ). Для всіх класів також необхідно перекрити метод ToString()
, для об'єктів групи – також Equals()
. Для представлення додаткових даних замість рядку необхідно створити відповідну структуру.
1.2 Розширення класу String
Розширити стандартний клас System.String
методом видалення зайвих пропусків (декілька пропусків замінювати одним).
1.3 Створення класу Complex
Створити клас Complex
(комплексне число), перевантажити операції + , -, *, / і операцію неявного приведення до типу string
. Під час тестування використовувати ініціалізатори об'єктів.
1.4 Створення класу Vector
Створити клас Vector
(математичний вектор у n-вимірному просторі). Описати необхідні конструктори. Перевантажити такі операції:
- + (сума векторів)
-
(різниця векторів)*
(множення на скаляр)*
(скалярний добуток)/
(ділення на скаляр)
Перекрити метод ToString()
. У функції Main()
створити три об'єкти типу Vector
та здійснити тестування створених операцій.
1.5 Корені рівняння
Реалізувати програму, що дозволяє знайти усі корені деякого рівняння на заданому інтервалі. Алгоритм знаходження коренів полягає в послідовному переборі з певним кроком точок інтервалу, знаходженні інтервалів, на яких функція змінює знак і виведенні середніх арифметичних початків та кінців інтервалів – коренів рівняння.
Реалізувати два підходи – через використання абстрактних класів і через використання інтерфейсів.
1.6 3D-точка
Реалізувати структуру (struct
) для представлення точки в тривимірному просторі. Реалізувати метод обчислення відстані від точки до початку координат. В окремому класі здійснити тестування структури.
2 Методичні вказівки
2.1 Ініціалізатори об'єктів
Традиційно в мовах об'єктно-орієнтованого програмування для ініціалізації об'єктів застосовують конструктори з параметрами. Але іноді таких конструкторів не вистачає і тоді після створення об'єктів значення необхідних властивостей встановлюють вручну. Використовуючи версію 3.0 мови C# (та пізніші версії), можна ініціалізувати відкриті властивості та відкриті поля у спеціальному блоці під час створення об'єкта. Вирази ініціалізації перелічуються через кому.
Припустимо, описано такий клас:
public class Point { public int X { get; set; } public int Y { get; set; } }
Можна запропонувати два варіанти:
Point p = new Point { X = 10, Y = 20 }; Point p1 = new Point() { X = 100, Y = 200 };
У наведених прикладах спочатку викликається конструктор без параметрів, після завершення роботи якого здійснюється запис указаних значень у властивості. Можна також викликати конструктор з параметрами.
public class Point { public int X { get; set; } public int Y { get; set; } public Point() { } public Point(int x, int y) { X = x; Y = y; } } ... Point p = new Point(1, 2) { X = 10, Y = 20 };
Оскільки блок ініціалізації виконується після виконання тіла конструктора, значення, визначені у блоці, перекривають значення, присвоєні у конструкторі. Більше сенсу має інший приклад, у якому ініціалізується властивість Color
відповідного типу, визначеного в просторі імен System.Drawing
. Координати точки можна визначити через конструктор, а для визначення кольору немає відповідного конструктора, тому доцільно скористатись ініціалізатором.
public class Point { public int X { get; set; } public int Y { get; set; } public System.Drawing.Color Color { get; set; } public Point() { } public Point(int x, int y) { X = x; Y = y; } } ... Point p = new Point(1, 2) { Color = System.Drawing.Color.Blue };
Іноді виникає потреба у вкладених ініціалізаторах. Припустимо, клас Rectangle
(прямокутник) містить опис властивостей типу Point
:
public class Rectangle { public Point LeftTop { get; set; } public Point RightBottom { get; set; } }
Тоді можна викликати конструктори всередині блоку ініціалізації:
Rectangle rect = new Rectangle { LeftTop = new Point(0, 0), RightBottom = new Point(30, 40) };
Можна також запропонувати таку вкладену ініціалізацію:
Rectangle rect = new Rectangle { LeftTop = new Point { X = 0, Y = 0 }, RightBottom = new Point { X = 30, Y = 40 } };
2.2 Статичні класи
Починаючи з версії 2.0, мова C# підтримує так звані статичні класи. Такі класи можуть містити тільки статичні елементи.
public static class Static { private static int k = 144; public static int GetK() { return k; } }
Статичні класи не можна використовувати для створення об'єктів, вони не можуть бути застосовані як базові. Статичний клас може мати статичний конструктор.
2.3 Перевантаження операцій
Під час проектування класу можна визначити набір операцій, які можна виконувати над об'єктами. До операцій, що перевантажуються, належать:
- унарні операції
+
,-
,!
,~
,++
,--
,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.4 Успадкування
Успадкування – це процес створення похідних класів від базових. Об'єкти похідних класів неявно містять усі поля базового класу, включаючи закриті, не зважаючи на те, що методи похідного класу не мають доступу до закритих елементів базового. Крім того, успадковуються всі відкриті властивості та методи. Елементи базового класу з модифікатором protected
(захищені) доступні з похідних класів.
На відмну від C++, C# дозволяє тільки одиничне успадкування класів. Успадкування завжди відкрите (похідний клас не може обмежити доступ до відкритих елементів базового класу). Успадкування має такий синтаксис:
class DerivedClass : BaseClass { // тіло класу }
Усі класи безпосередньо чи опосередковано походять від класу System.Object
.
Конструктори не успадковуються – для кожного класу ієрархії повинен бути реалізований свій власний конструктор. Перед виконанням конструктора похідного класу викликається конструктор базового класу і конструктори об'єктів, які створюються під час опису класу.
Ключове слово base
використовують для доступу до елементів базового класу з похідного класу, зокрема:
- для виклику перекритого методу базового класу;
- для передачі параметрів конструктора базового класу.
Наприклад:
class BaseClass { int i, j; public BaseClass(int i, int j) { this.i = i; this.j = j; } } class DerivedClass : BaseClass { int k;
public DerivedClass() : base(0, 0)
{
k = 0;
} public DerivedClass(int i, int j, int k) : base(i, j) { this.k = k; } }
Класи можуть бути визначені з модифікатором sealed
. Такі класи не можуть використовуватися як базові. Методи з модифікатором sealed
не можуть бути перекриті.
Як і в інших мовах об'єктно-орієнтованого програмування, існує неявне приведення типу посилання на похідний клас до посилання на базовий, але не навпаки.
BaseClass b = new DerivedClass(); DerivedClass d = new DerivedClass(); b = d; // ОК d = b; // Помилка компіляції!
Для того щоб привести посилання на базовий клас до посилання на похідний клас, може бути використане явне перетворення. У випадку, якщо таке перетворення неможливе, генерується виняткова ситуація System.InvalidCastException
. Працювати з винятковими ситуаціями не завжди зручно. Існує спеціальна форма приведення типів з перевіркою можливості такого приведення – операція as
:
BaseClass b1 = new DerivedClass(); BaseClass b2 = new BaseClass(); DerivedClass d1 = b1 as DerivedClass; // OK DerivedClass d2 = b2 as DerivedClass; // null
Операція is
повертає true
, якщо перетворення можливе, і false
у протилежному випадку:
BaseClass b1 = new DerivedClass(); BaseClass b2 = new BaseClass(); if (b1 is DerivedClass) { DerivedClass d1 = (DerivedClass) b1; // OK } if (b2 is DerivedClass) { DerivedClass d2 = (DerivedClass) b2; // не виконується }
Конструкції as
та is
запозичені з Delphi Pascal.
2.5 Поліморфізм. Інтерфейси
Поліморфізм часу виконання – це властивість класів, згідно з якою поведінка об'єктів класу може визначатися не на етапі компіляції, а на етапі виконання. Поняття поліморфізму та поліморфних класів загалом спільні в усіх мовах об'єктно-орієнтованого програмування, зокрема, в Java та C#.
Усі класи C# є поліморфними, оскільки вони походять від поліморфного класу System.Object.
Як і в інших мовах
об'єктно-орієнтованого програмування, поліморфізм у C# реалізований через механізм віртуальних методів. Як і в
C++, перед заголовком віртуального методу необхідно вказати модифікатор virtual
у базовому
класі і модифікатори override
у
похідних класах. Якщо ми хочемо перекрити віртуальний метод, обірвавши ланцюжок поліморфізму, необхідно використовувати
ключове слово new
:
class Shape { public virtual void Draw() { . . . } } class Circle : Shape { public override void Draw() { . . . } } class Rectangle : Shape { public new void Draw() { . . . } }
Найчастіше виникає необхідність у перекритті віртуальних методів класу System.Object.
Наприклад, для того, щоб отримати представлення об'єкту у вигляді рядка, необхідно для класу визначити метод ToString()
, який повертає необхідний рядок. Таке представлення можна вживати з будь-якою метою, наприклад, виводити усі дані про об'єкт за допомогою функції Console.WriteLine()
:
class MyClass { int k; double x; public MyClass(int k, double x) { this.k = k; this.x = x; } public override string ToString() { return k + " " + x; } static void Main(string[] args) { MyClass mc = new MyClass(1, 2.5); Console.WriteLine(mc); } }
Можна також перекрити метод Equals()
, який дозволяє та порівнювати об'єкти між собою.
Іноді класи створюють для представлення абстрактних концепцій, а не для створення екземплярів. Такі концепції можуть бути представлені абстрактними класами. У C# для цього використовується ключове слово abstract
перед визначенням класу.
abstract class SomeConcept { . . . }
Абстрактний клас може містити абстрактні методи, такі, для яких не приводиться реалізація. Такі методи не мають тіла функції. Їхнє оголошення аналогічне оголошенню функцій-елементів у С++, але оголошенню повинне передувати ключове слово abstract
. У такому випадку мається на увазі, що метод – віртуальний, але вказувати слово virtual
не можна. Методи, що перевантажують абстрактний метод, у похідних класах повинні мати модифікатор override
.
Наприклад, абстрактний клас Shape
(геометрична фігура) реалізує поля і методи, що можуть бути використані різними похідними класами. До таких полів можна, наприклад, віднести поточну позицію і метод переміщення екраном MoveTo()
. У класі Shape
також оголошені абстрактні методи, такі як Draw()
, що повинні бути реалізовані у всіх похідних класах, але по-різному. Усталена реалізація не має сенсу. Наприклад:
abstract class Shape { int x, y; . . . public void MoveTo(int newX, int newY) { . . . Draw(); } public abstract void Draw(); }
Конкретні класи, створені від Shape
, такі як Circle
чи Rectangle
, визначають реалізацію методу Draw()
.
class Circle : Shape { public override void Draw() { . . . } } class Rectangle : Shape { public override void Draw() { . . . } }
Від абстрактного класу не вимагають обов'язкову наявність абстрактних методів. Але кожен клас, у якому є хоч один абстрактний метод, чи хоча б один абстрактний метод базового класу не був визначений, повинен бути оголошений як абстрактний (з використанням ключового слова abstract
).
Абстрактні класи можуть містити абстрактні властивості, для таких властивостей не задається код доступу, а тільки позначається необхідність завдання такого коду в нащадках. Наприклад:
abstract class Shape { public abstract double Area { get; } } ... class Rectangle : Shape { double width, height; public Rectangle(double width, double height) { this.width = width; this.height = height; } public override double Area { get { return width * height; } } }
Примітка: не слід плутати абстрактні властивості з автоматичними, які мають схожий синтаксис.
У C# використовується поняття інтерфейсів. Інтерфейс схожий на суто абстрактний клас, що містить тільки абстрактні методи та властивості:
interface Int1 { void F(); int G(int x); int P { get; set; } }
Насправді інтерфейс не є типом даних і фактично тільки декларує певну поведінку, яку повинен забезпечити клас, який реалізує інтерфейс.
Кожен клас може бути створений тільки від одного базового класу, але при цьому реалізовувати один чи кілька інтерфейсів. Клас, що реалізує інтерфейс, повинен забезпечити реалізацію всіх методів, оголошених в інтерфейсі. У іншому разі такий клас буде абстрактним і має бути оголошений зі специфікатором abstract
.
Інтерфейси, що реалізуються класом, перелічуються через кому там же, де вказується базовий клас. Базовий клас повинен завжди бути першим у цьому списку. Для того, щоб розрізняти базові класи від інтерфейсів, імена інтерфейсів рекомендується починати з великої літери I
.
Методи та властивості, визначені в інтерфейсі, є абстрактними і відкритими (цього явно не пишуть). У класі, який реалізує інтерфейс, такі методи повинні бути оголошені як public
:
interface ISomeFuncs { void F(); int G(int x); } class SomeClass : ISomeFuncs { public void F() { } public int G(int x) { return x; } }
Клас може реалізувати кілька інтерфейсів:
interface IFirst { void F(); int G(int x); } interface ISecond { void H(int z); } class AnotherClass : IFirst, ISecond { public void F() { } public int G(int x) { return x; } public void H(int z) { } }
Можна створювати похідні інтерфейси. До інтерфейсів можна застосовувати множинне успадкування:
interface IFirst { void F(); int G(int x); } interface ISecond { void H(int z); } interface IDerived : IFirst, ISecond { }
Інтерфейс може містити оголошення властивостей. Також можна визначити константи в інтерфейсах.
В С# існує додаткова можливість визначення явної реалізації інтерфейсів, що запобігає конфліктам імен у класах, які реалізують декілька інтерфейсів. У класі, який реалізує інтерфейс, ім'я функції, визначеної в інтерфейсі, складається з двох частин.
тип Ім'я_інтерфейсу.ім'я_методу() { ...// реалізація }
Для таких методів не вказується рівень доступу. Методи класу, які явно реалізують інтерфейс, можуть викликатися тільки через ім'я посилання на інтерфейс, а не на клас, який його реалізує.
Починаючи C# 8 можна визначати усталену реалізацію методів інтерфейсу. Мета усталеної реалізації – додавати нові методи до раніше визначених інтерфейсів без модифікації класів, що реалізували попередні версії інтерфейсів. Метод можна реалізувати без будь-яких модифікаторів. Також можна визначити статичні методи:
public interface IGreetings { void Hello() { Console.WriteLine("Hello world!"); } static void HelloStatic() { Console.WriteLine("Hello as well!"); } }
Нестатичні методи можна викликати лише через посилання на інтерфейс (не через посилання на клас, який реалізує інтерфейс):
class Greetings : IGreetings { public void TestHello() { IGreetings greetings = this; greetings.Hello(); } } class Program { static void Main(string[] args) { new Greetings().TestHello(); } }
Статичні методи можна викликати через ім'я інтерфейсу:
class Program { static void Main(string[] args) { IGreetings.HelloStatic(); } }
Методи з усталеною реалізацією можна перевизначити в похідних інтерфейсах. Такі методи також можна оголосити як абстрактні:
public interface IMyGreetings : IGreetings { void IGreetings.Hello() { Console.WriteLine("Hello to me!"); } } public interface IAbstractGreetings : IGreetings { abstract void IGreetings.Hello(); }
Тепер цей метод повинен бути реалізований у класах, що реалізують інтерфейс IAbstractGreetings
.
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.ValueType
, що є нащадком System.Object
. Разом з тим, структури не підтримують механізму явного успадкування, хоча і можуть реалізовувати інтерфейси. У структурі можна перевизначити методи класу System.Object
, наприклад, такі як ToStrіng()
. Перевизначення здійснюється з використанням модифікатора overrіde
, що в інших випадках для структур заборонене.
Оскільки структури є типами-значеннями, під час їхнього використання там, де очікується посилання на об'єкт класу (наприклад, у присвоюванні їх об'єкту типу 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 }
Для звертання до елементів, визначених у переліку, задається ім'я переліку і через крапку відповідна константа. Наприклад: Dіgіts.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))); }
Однією з найважливіших переваг кортежів є можливість повернення декількох значень із функції. Наведений нижче приклад демонструє використання кортежів для повернення трьох значень з функції. Ця функція розв'язує квадратне рівняння. Перше значення типу bool
є істинним, якщо рівняння можна розв’язати. Ще два значення є коренями рівняння:
static (bool Solvable, double? X1, double? X2) SolveQuadratic(double a, double b, double c) { if (a == 0 || D() < 0) { return (false, null, null); } return (true, X(-1), X(+1)); double D() { return b * b - 4 * a * c; } double X(int k) { return (-b + k * Sqrt(D())) / (2 * a); } } static void Main(string[] args) { var result = SolveQuadratic(1, 2, 1); Console.WriteLine(result.Solvable + " " + result.X1 + " " + result.X2); // True -1 -1 result = SolveQuadratic(1, 2, 3); Console.WriteLine(result.Solvable + " " + result.X1 + " " + result.X2); // False }
Примітка: цей приклад також демонструє використання локальних функцій.
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; } }
В інтерфейси також можна вкладати інші типи.
2.11 Зіставлення зі зразком
Концепція зіставлення зі зразком (Pattern matching) є розвитком ідеї реалізації алгоритмів з розгалуженням. На загальному рівні зіставлення зі зразком передбачає виконання програмного коду залежно від збігу значення, яке досліджується, з певним зразком. Залежно від можливостей мови програмування це може бути
- константа,
- предикат,
- тип даних,
- інша конструкція мови програмування.
Традиційні засоби порівняння значень змінних з константами в твердженнях if
та switch
є найпростішими формами зіставлення зі зразком. Коли у мові C# йдеться про зіставлення зі зразком, мають на увазі додані починаючи з версії C# 7.0 конструкції перевірки типів об'єктів з одночасним створенням посилання на змінну відповідного типу. Наприклад, у нас є змінна:
object obj = "Текст";
Десь у програмі слід перевірити чи справді ця змінна посилається на рядок, та виконати певні дії. До введення нових конструкцій, пов'язаних із зіставлення зі зразком слід було здійснювати явне перетворення типів:
if (obj is string) { string s = (string)obj; Console.WriteLine(s.Length); }
Починаючи з версії C# 7.0 можна використовувати більш компактну конструкцію:
if (obj is string s) { Console.WriteLine(s.Length); }
Але найбільш цікава новація - це використання перевірки типів у конструкції switch()
. Наприклад:
switch (obj) { case string s: Console.WriteLine(s.Length); break; case int i: Console.WriteLine(i + 1); break; default: Console.WriteLine("Хибний тип"); break; }
У виразах зіставлення зі зразком можна використовувати конструкцію when
для визначення додаткової умови, наприклад:
switch (obj) { case string s when s.Length == 0: Console.WriteLine("Порожній рядок"); break; case string s: Console.WriteLine(s); break; default: Console.WriteLine("Хибний тип"); break; }
2.12 Використання атрибутів
Атрибути C# використовуються для додавання метаданих до елементів коду, таких як класи, методи та властивості. Завдяки атрибутам до елемента коду можна додати інформацію, яка не може бути визначена засобами мови C#. Обробка атрибутів здійснюється під час компіляції. Атрибут розташовують в квадратних дужках перед елементом коду, до якого цей атрибут відноситься. Існує декілька стандартних атрибутів, які завжди можна використовувати в програмному коді.
Як приклад стандартного атрибута можна навести атрибут [Obsolete]
, яким помічаються застарілі методи, використання яких
є небажаним. Після імені атрибута в дужках вказується параметр – рядок, який інформує про те, що цей метод застарілий
і що саме рекомендовано вживати замість цього методу:
class Program { [Obsolete("This method is deprecated, use Good instead.")] static void Bad() { } static void Good() { } static void Main(string[] args) { Bad(); // попередження: виклик небажаного методу } }
Можна взагалі заборонити використання застарілих методів вказавши другим параметром true
, що означає генерацію
помилки під час виклику:
class Program { [Obsolete("This method is deprecated, use Good instead.", true)] static void Bad() { } static void Good() { } static void Main(string[] args) { Bad(); // синтаксична помилка } }
Можна також використовувати такі атрибути, як [Serializable]
, [DefaultValue]
, [MaxLength]
, [MinLength]
тощо.
Для створення власного атрибута необхідно описати клас, похідний від System.Attribute
. Клас може містити
поля, властивості, методи тощо. Параметри конструкторів класу вказуються під час застосування атрибутів у програмному
коді.
3 Приклади програм
3.1 Ієрархія об'єктів реального світу
Припустимо, необхідно розробити ієрархію класів "Регіон" – "Населений район" – "Країна". Окремі класи цієї ієрархії можуть стати базовими для інших класів (наприклад "Незаселений острів", "Національний парк", "Адміністративний район", "Автономна республіка" і т.д.). Ієрархію класів можна доповнити класами "Місто" і "Острів". Доцільно в кожен клас додати конструктор, який ініціалізує усі поля. Можна також створити масив посилань на різні об'єкти ієрархії і. для кожного об'єкта вивести на екран рядок даних про нього.
Для того, щоб одержати рядкове представлення об'єкта, необхідно перекрити метод ToString()
. Можна запропонувати таку ієрархію класів:
namespace HierarchyTest { // ієрархiя класів class Region { public string Name { get; set; } public double Area { get; set; } public Region(string name, double area) { Name = name; Area = area; } public override string ToString() { return Name + ".\nТериторiя " + Area + " кв.км.\n"; } } class PopulatedRegion : Region { public int Population { get; set; } public PopulatedRegion(string name, double area, int population) : base(name, area) { Population = population; } public int Density() { return (int) (Population / Area); } public override string ToString() { return base.ToString() + "Населення " + Population + " чол.\n" + "Щiльнiсть населення " + Density() + " чол/кв.км.\n"; } } class Country : PopulatedRegion { public string Capital { get; set; } public Country(string name, double area, int population, string capital) : base(name, area, population) { Capital = capital; } public override string ToString() { return "Країна " + base.ToString() + "Столиця " + Capital + "\n"; } } class City : PopulatedRegion { public int Boroughs { get; set; } // Кiлькiсть районiв public City(string name, double area, int population, int boroughs) : base(name, area, population) { Boroughs = boroughs; } public override string ToString() { return "Мiсто " + base.ToString() + "Районiв - " + Boroughs + "\n"; } } class Island : PopulatedRegion { public string Sea { get; set; } public Island(string name, double area, int population, string sea) : base(name, area, population) { Sea = sea; } public override string ToString() { return "Острiв " + base.ToString() + "Море - " + Sea + "\n"; } } class Program { static void Main(string[] args) { Region[] a = { new City("Київ", 839, 2679000, 10), new Country("Україна", 603700, 46294000, "Київ"), new City("Харкiв", 310, 1461000, 9), new Island("Змiїний", 0.2, 30, "Чорне") }; foreach (Region region in a) { System.Console.WriteLine(region); } } } }
3.2 Знаходження мінімуму методом дихотомії
Припустимо, необхідно створити універсальний клас для знаходження методом дихотомії мінімуму будь-якої функції f(x). Алгоритм знаходження мінімуму на певному інтервалі [a, b] з точністю h полягає в такому:
- визначається середина інтервалу (x)
- обчислюються значення f(x - h) та f(x + h)
- якщо f(x - h) > f(x + h), початок інтервалу переноситься в x, в іншому випадку туди переноситься кінець інтервалу
- якщо довжина нового інтервалу менша, ніж h, процес завершується, в іншому випадку – повторюється для нового інтервалу
Слід зазначити, що цей алгоритм працює тільки для випадку, коли на інтервалі один мінімум.
Можна запропонувати два підходи: через використання абстрактних класів і через використання інтерфейсів.
Перший варіант
Створюємо новий клас – AbstractMinimum
, який містить абстрактний метод F()
і метод знаходження мінімуму – Solve()
. У похідному класі цей метод перекривається.
using System; namespace LabSecond { public abstract class AbstractMinimum { abstract public double F(double x); public double Solve(double a, double b, double h) { double x = (a + b) / 2; while (Math.Abs(b - a) > h) { if (F(x - h) > F(x + h)) { a = x; } else { b = x; } x = (a + b) / 2; } return x; } } class SpecificMinimum : AbstractMinimum { public override double F(double x) { return x * x - x; } } class Program { static void Main(string[] args) { SpecificMinimum sm = new SpecificMinimum(); Console.WriteLine(sm.Solve(0, 3, 0.000001)); } } }
Другий варіант
Описуємо інтерфейс для представлення функції. Клас Solver
реалізує статичний метод для знаходження мінімуму функції. Клас, який реалізує інтерфейс, містить конкретну реалізацію функції F()
.
using System; namespace LabSecond { public interface IFunction { double F(double x); } public class Solver { public static double Solve(double a, double b, double h, IFunction func) { double x = (a + b) / 2; while (Math.Abs(b - a) > h) { if (func.F(x - h) > func.F(x + h)) { a = x; } else { b = x; } x = (a + b) / 2; } return x; } } class MyFunc : IFunction { public double F(double x) { return x * x - x; } } class Program { static void Main(string[] args) { Console.WriteLine(Solver.Solve(0, 3, 0.000001, new MyFunc())); } } }
3.3 Ієрархія книжкових полиць
Припустимо, необхідно доробити попередньо створену програму, яка описує книжкову полицю, додавши ієрархію книжкових полиць. Наприклад, окремий тип полиці – полиця з назвою. Для обох варіантів книжкових полиць слід визначити операції додавання книжок (+) та видалення книжок (-
). Для всіх класів також необхідно перекрити метод ToString()
, для книжок та авторів – також Equals()
. Для представлення автора необхідно створити окрему структуру.
Наведемо повний текст програми:
using System; namespace LabSecond { // Структура для опису автора public struct Author { public string Surname, Name; //Перевизначення еквівалентності public override bool Equals(object? obj) { if (obj == null) { return false; } Author author = (Author)obj; return author.Surname == Surname && author.Name == Name; } // Визначення представлення у вигляді рядку: public override string ToString() { return Name + " " + Surname; } // Визначається у парі з Equals() public override int GetHashCode() { return base.GetHashCode(); } public static bool operator ==(Author left, Author right) { return left.Equals(right); } public static bool operator !=(Author left, Author right) { return !(left == right); } } // Книжка public class Book { public string Title { get; set; } public int Year { get; set; } public Author[] Authors { get; set; } // Конструктор public Book(string title, int year, params Author[] authors) { Title = title; Year = year; Authors = authors; } // Визначення представлення у вигляді рядку // string.Format() забезпечує форматування, аналогічне Console.WriteLine() public override string ToString() { string s = string.Format("Назва: \"{0}\". Рiк видання: {1}", Title, Year); s += "\n" + " Автор(и):"; for (int i = 0; i < Authors.Length; i++) { s += string.Format(" {0}", Authors[i]); if (i < Authors.Length - 1) { s += ","; } else { s += "\n"; } } return s; } // Перевизначення еквівалентності public override bool Equals(object? obj) { if (obj is Book b) { if (b.Authors.Length != Authors.Length) { return false; } for (int i = 0; i < Authors.Length; i++) { if (!b.Authors[i].Equals(Authors[i])) { return false; } } return b.Title == Title && b.Year == Year; } return false; } // Визначається у парі з Equals() public override int GetHashCode() { return base.GetHashCode(); } } // Книжкова полиця public class Bookshelf { public Book[] Books { get; set; } // Конструктор public Bookshelf(params Book[] books) { Books = books; } // Індексатор public Book this[int index] { get { return Books[index]; } set { Books[index] = value; } } // Визначення представлення у вигляді рядку public override string ToString() { string result = ""; foreach (Book book in Books) { result += book; } return result; } // Пошук книжки з певною послідовністю літер public Book[] ContainsCharacters(string characters) { Book[] found = Array.Empty<Book>(); foreach (Book book in Books) { if (book.Title.Contains(characters)) { // Додаємо новий елемент до масиву: Array.Resize(ref found, found.Length + 1); found[^1] = book; } } return found; } // Додавання книжки public void Add(Book book) { Book[] books = Books; Array.Resize(ref books, Books.Length + 1); Books = books; Books[^1] = book; } // Видалення книжки зі вказаними даними public void Remove(Book book) { int i, k; Book[] newBooks = new Book[Books.Length]; for (i = 0, k = 0; i < Books.Length; i++, k++) { if (Books[i].Equals(book)) { k--; } else { newBooks[k] = Books[i]; } } if (i > k) { Array.Resize(ref newBooks, Books.Length - 1); } Books = newBooks; } // Перевантажений оператор додавання книжки public static Bookshelf operator +(Bookshelf bookshelf, Book book) { Bookshelf newBookshelf = new(bookshelf.Books); newBookshelf.Add(book); return newBookshelf; } // Перевантажений оператор видалення книжки public static Bookshelf operator -(Bookshelf bookshelf, Book book) { Bookshelf newBookshelf = new(bookshelf.Books); newBookshelf.Remove(book); return newBookshelf; } } // Книжкова полиця з назвою public class TitledBookshelf : Bookshelf { public string Title { get; set; } public TitledBookshelf(string title, params Book[] books) : base(books) { Title = title; } // Визначення представлення у вигляді рядку public override string ToString() { return Title + "\n" + base.ToString(); } // Перевантажений оператор додавання книжки public static TitledBookshelf operator +(TitledBookshelf titled, Book book) { TitledBookshelf newBookshelf = new(titled.Title, titled.Books); newBookshelf.Add(book); return newBookshelf; } // Перевантажений оператор видалення книжки public static TitledBookshelf operator -(TitledBookshelf titled, Book book) { TitledBookshelf newBookshelf = new(titled.Title, titled.Books); newBookshelf.Remove(book); return newBookshelf; } } class Program { static void Main() { // Створюємо порожню полицю: Bookshelf bookshelf = new(); // Додаємо книжки bookshelf += new Book("The UML User Guide", 1999, new Author() { Name = "Grady", Surname = "Booch" }, new Author() { Name = "James", Surname = "Rumbaugh" }, new Author() { Name = "Ivar", Surname = "Jacobson" }); bookshelf += new Book(@"Об'єктно-орiєнтоване моделювання програмних систем", 2007, new Author() { Name = "Iгор", Surname = "Дудзяний" }); bookshelf += new Book("Thinking in Java", 2005, new Author() { Name = "Bruce", Surname = "Eckel" }); // Виводимо дані на екран: Console.WriteLine(bookshelf); Console.WriteLine(); // Шукаємо книжки з певною послідовністю літер: Console.WriteLine("Уведiть послiдовнiсть лiтер:"); string sequence = Console.ReadLine() ?? ""; Bookshelf newBookshelf = new(bookshelf.ContainsCharacters(sequence)); // Виводимо результат на екран: Console.WriteLine("Знайденi книжки:"); Console.WriteLine(newBookshelf); Console.WriteLine(); // Видаляємо книжку про Java Book javaBook = bookshelf[2]; // індексатор bookshelf -= javaBook; Console.WriteLine("Пiсля видалення книжки:"); Console.WriteLine(bookshelf); Console.WriteLine(); // Створюємо нову полицю TitledBookshelf titledBookshelf = new("Java"); titledBookshelf += javaBook; Console.WriteLine("Нова полиця:"); Console.WriteLine(titledBookshelf); } } }
4 Вправи для контролю
- Розширити тип
int
методом обчислення квадрату. - Розширити тип
double
методом обчислення третього степеня. - Розширити тип
int
методом обчислення факторіалу. - Розширити тип
double
методом обчислення цілого степеню. - Розширити клас
System.String
, додавши функцію видалення першої та останньої літери. - Розширити клас
System.String
, додавши функцію перевірки, чи збігаються перша та остання літери. - Створити клас "Група людей". Реалізувати перевантаження операцій
+
та-
для додавання та видалення учасників групи. Використовувати ініціалізатори об'єктів. - Створити клас "Простий дріб". Реалізувати перевантаження операцій
+
, - та*
. - Створити ієрархію класів Книга та Підручник. Реалізувати конструктори та методи доступу. Перекрити метод
ToString()
. У функціїMain()
створити масив, який містить елементи різних типів. Вивести елементи на екран. - Створити ієрархію класів Кінофільм і Серіал. Реалізувати конструктори та методи доступу. Перекрити метод
ToString()
. У функціїMain()
створити масив, який містить елементи різних типів. Вивести елементи на екран. - Створити ієрархію класів Місто та Столиця. Реалізувати конструктори та методи доступу. Перекрити метод
ToString()
. У функціїMain()
створити масив, який містить елементи різних типів. Вивести елементи на екран. - Створити ієрархію класів "Домашня тварина" та "Кішка". Перекрити метод
ToString()
. У функціїMain()
створити масив, який містить елементи різних типів. Вивести елементи на екран. - Створити ієрархію класів "Планета" та "Супутник". Перекрити метод
ToString()
. У функціїMain()
створити масив, який містить елементи різних типів. Вивести елементи на екран. - Створити клас "Група" зі вкладеною структурою для опису студента та масивом студентів.
- Створити клас "Книжкова шафа" зі вкладеною структурою для опису книжки та масивом книжок.
- Створити перелік для представлення днів тижня. Вивести усі значення на екран.
- Створити перелік для представлення місяців. Вивести усі значення на екран.
5 Контрольні запитання
- У чому переваги та недоліки ініціалізаторів об'єктів у порівнянні з конструкторами?
- Чи можна ініціалізувати закриті поля та властивості за допомогою ініціалізаторів об'єктів?
- Як здійснюється опис статичного класу?
- У чому недоліки та переваги статичних класів?
- Для чого здійснюється перевантаження операцій?
- Як здійснюється опис операторної функції?
- Чи можна викликати операторну функцію замість застосування відповідного оператору?
- Для чого здійснюється успадкування класів?
- Яка різниця між множинним та одиничним успадкуванням?
- Які елементи базового класу не успадковуються?
- Як здійснити ініціалізацію базового класу?
- Де і для чого можна застосовувати ключове слово
base
? - Як перекрити метод з модифікатором
sealed
? - Чи можна неявно приводити посилання на базовий клас до посилання на похідний клас?
- Які можливості надає використання поліморфізму?
- Чим віртуальний метод відрізняється від невіртуального?
- Як здійснюється опис та перекриття віртуальних методів?
- Яка реакція компілятора на відсутність
override
абоnew
перед перекритим віртуальним методом? - Як описати абстрактні методи та класи?
- Чи можуть абстрактні класи містити неабстрактні методи?
- У чому перевага інтерфейсів у порівнянні з абстрактними класами?
- Як описати та реалізувати інтерфейс?
- Чи дозволене множинне успадкування інтерфейсів?
- Навіщо здійснюється явна реалізація інтерфейсів?
- У чому полягають особливості методів, які явно реалізують інтерфейси?
- Як описати і викликати метод інтерфейсу з усталеною реалізацією?
- У чому сенс використання безіменних типів?
- У чому особливості створення і використання записів?
- У чому переваги та недоліки структур?
- Як описати структуру?
- Як для структури визначити усталений конструктор?
- У чому полягають особливості створення об'єкта-структури за допомогою
new
? - Як здійснюється упакування структур?
- Як можна описати та використовувати переліки?
- Синтаксис та переваги застосування кортежів.
- Чи можна додавати статичні методи до раніше створених класів без зміни їхнього вихідного коду?
- Як здійснюється опис методу, який додається до існуючого класу?
- Чи можна створити вкладений клас усередині інтерфейсу?