Глава 20. Принципи на обектно-ориентираното програмиране
В тази тема...
В настоящата тема ще се запознаем с принципите на обектно-ориентираното програмиране: наследяване на класове и имплементиране на интерфейси, абстракция на данните и поведението, капсулация на данните и скриване на информация за имплементацията на класовете, полиморфизъм и виртуални методи. Ще обясним в детайли принципите за свързаност на отговорностите и функционално обвързване (cohesion и coupling). Ще опишем накратко как се извършва обектно-ориентирано моделиране и как се създава обектен модел по описание на даден бизнес проблем. Ще се запознаем с езика UML и ролята му в процеса на обектно-ориентираното моделиране. Накрая ще разгледаме съвсем накратко концепцията "шаблони за дизайн" и ще дадем няколко типични примера за шаблони, широко използвани в практиката.
Съдържание
- Видео
- Презентация
- Мисловни карти
- В тази тема...
- Да си припомним: класове и обекти
- Обектно-ориентирано програмиране (ООП)
- Основни принципи на ООП
- Наследяване (Inheritance)
- Абстракция (Abstraction)
- Капсулация (Encapsulation)
- Полиморфизъм (Polymorphism)
- Свързаност на отговорностите и функционално обвързване (cohesion и coupling)
- Обектно-ориентирано моделиране (OOM)
- Нотацията UML
- Шаблони за дизайн
- Упражнения
- Решения и упътвания
- Демонстрации (сорс код)
- Дискусионен форум
Видео
https://www.youtube.com/watch?time_continue=5&v=1I2_BB-QT2c&feature=emb_title
Презентация
Мисловни карти
Да си припомним: класове и обекти
С класове и обекти се запознахме в главата "Създаване и използване на обекти".
Класовете са описание (модел) на реални предмети или явления, наречени същности (entities). Например класът "Студент".
Класовете имат характеристики – в програмирането са наречени свойства (properties). Например съвкупност от оценки.
Класовете имат и поведение – в програмирането са наречени методи (methods). Например явяване на изпит.
Методите и свойствата могат да бъдат видими само в областта на класа, в който са декларирани и наследниците му (private/protected), или видими за всички останали класове (public).
Обектите (objects) са екземпляри (инстанции) на класовете. Например Иван е студент, Петър също е студент.
Обектно-ориентирано програмиране (ООП)
Обектно-ориентираното програмиране е наследник на процедурното (структурно) програмиране. Процедурното програмиране най-общо казано описва програмите чрез група от преизползваеми парчета код (процедури), които дефинират входни и изходни параметри. Процедурните програми представляват съвкупност от процедури, които се извикват една друга.
Проблемът при процедурното програмиране е, че преизползваемостта на кода е трудно постижима и ограничена – само процедурите могат да се преизползват, а те трудно могат да бъдат направени общи и гъвкави. Няма лесен начин да се реализират абстрактни структури от данни, които имат различни имплементации.
Обектно-ориентираният подход залага на парадигмата, че всяка програма работи с данни, описващи същности (предмети и явления) от реалния живот. Например една счетоводна програма работи с фактури, стоки, складове, наличности, продажби и т.н.
Така се появяват обектите – те описват характеристиките (свойства) и поведението (методи) на тези същности от реалния живот.
Основни предимства и цели на ООП – да позволи по-бърза разработка на сложен софтуер и по-лесната му поддръжка. ООП позволява по лесен начин да се преизползва кода, като залага на прости и общоприети правила (принципи). Нека ги разгледаме.
Основни принципи на ООП
За да бъде един програмен език обектно-ориентиран, той трябва не само да позволява работа с класове и обекти, но и трябва да дава възможност за имплементирането и използването на принципите и концепциите на ООП: наследяване, абстракция, капсулация и полиморфизъм. Сега ще разгледаме в детайли всеки от тези основни принципи на ООП.
- Капсулация (Encapsulation)
Ще се научим да скриваме ненужните детайли в нашите класове и да предоставяме прост и ясен интерфейс за работа с тях.
- Наследяване (Inheritance)
Ще обясним как йерархиите от класове подобряват четимостта на кода и позволяват преизползване на функционалност.
- Абстракция (Abstraction)
Ще се научим да виждаме един обект само от гледната точка, която ни интересува, и да игнорираме всички останали детайли.
- Полиморфизъм (Polymorphism)
Ще обясним как да работим по еднакъв начин с различни обекти, които дефинират специфична имплементация на някакво абстрактно поведение.
Наследяване (Inheritance)
Наследяването е основен принцип от обектно-ориентираното програмиране. То позволява на един клас да "наследява" (поведение и характеристики) от друг, по-общ клас. Например лъвът е от семейство котки. Всички котки имат четири лапи, хищници са, преследват жертвите си. Тази функционалност може да се напише веднъж в клас Котка и всички хищници да я преизползват – тигър, пума, рис и т.н.
Как се дефинира наследяване в .NET?
Наследяването в .NET става със специална структура при декларацията на класа. В .NET и други модерни езици за програмиране един клас може да наследи само един друг клас (single inheritance), за разлика от C++, където се поддържа множествено наследяване (multiple inheritance). Ограничението е породено от това, че при наследяване на два класа с еднакъв метод е трудно да се реши кой от тях да се използва (при C++ този проблем е решен много сложно). В .NET могат да се наследяват множество интерфейси, за които ще говорим по-късно.
Класът, който наследяваме, се нарича клас-родител или още базов клас (base class, super class).
Наследяване на класове – пример
Да разгледаме един пример за наследяване на класове в .NET. Ето как изглежда базовият (родителски) клас:
Felidae.cs |
/// <summary> /// Felidae is latin for "cat" /// </summary> public class Felidae { private bool male;
// This constructor calls another .ctor public Felidae() : this(true) {}
// This is the .ctor that is inherited public Felidae(bool male) { this.male = male; }
public bool Male { get { return male; } set { this.male = value; } } } |
Ето как изглежда и класът-наследник Lion:
Lion.cs |
public class Lion : Felidae { private int weight;
// Shall be explained in the next paragraph public Lion(bool male, int weight) : base(male) { this.weight = weight; }
public int Weight { get { return weight; } set { this.weight = value; } } } |
Ключовата дума base
В горния пример в конструктора на класа Lion използваме ключовата дума base. Тя указва да бъде използван базовият клас и позволява достъп до негови методи, конструктори и член-променливи. С base() можем да извикваме конструктор на базовия клас. С base.method(…) можем да извикваме метод на базовия клас, да му подаваме параметри и да използваме резултата от него. С base.field можем да вземем стойността на член-променлива на базовия клас или да й присвоим друга стойност.
В .NET наследените от базовия клас методи, които са декларирани като виртуални (virtual) могат да се пренаписват (override). Това означава да им се подмени имплементацията, като оригиналният сорс код от базовия клас се игнорира, а на негово място се написва друг код. Повече за пренаписването на методи ще обясним в секцията "Виртуални методи".
Можем да извикваме непренаписан метод от базовия клас и без base. Употребата на ключовата дума е необходима само ако имаме пренаписан метод или променлива със същото име в наследения клас.
base може да се използва изрично, за яснота. base. method(…) извиква метод, който задължително е от базовия клас. Такъв код се чете по-лесно, защото знаем къде да търсим въпросния метод. Имайте предвид, че ситуацията с this не е такава. this може да означава както метод от конкретния клас, така и метод от който и да е базов клас. |
Можете да погледнете примера в секцията нива на достъп при наследяване. В него ясно се вижда до кои членове (методи, конструктори и член-променливи) на базовия клас имаме достъп.
Конструкторите при наследяване
При наследяване на един клас, нашите конструктори задължително трябва да извикат конструктор на базовия клас, за да може и той да инициализира член-променливите си. Ако не го направим изрично, в началото на всеки наш конструктор компилаторът поставя извикване на базовия конструктор без параметри: ":base()". Ето и пример:
public class ExtendingClass : BaseClass { public ExtendingClass() } |
Всъщност изглежда така (намерете разликите J):
public class ExtendingClass : BaseClass { public ExtendingClass() : base() } |
Ако базовият клас няма конструктор по подразбиране (без параметри) или този конструктор е скрит, нашите конструктори трябва да извикат изрично някои от другите конструктори на базовия клас. Липсата на изрично извикване предизвиква грешка при компилация.
Ако един клас има само невидими конструктори (private), то това означава, че той не може да бъде наследяван. Ако един клас има само невидими конструктори (private), то това означава още много неща – например, че никой не може да създава негови инстанции освен самият той. Всъщност точно по този начин се имплементира един от най-известните шаблони описан накрая на тази глава – нарича се Singleton. |
Конструкторите и base – пример
Разгледайте класа Lion от последния пример, той няма конструктор по подразбиране. Да разгледаме следния клас-наследник на Lion:
AfricanLion.cs |
public class AfricanLion : Lion { // ...
// If we comment the next line with ":base(male, weight)" // the class will not compile. Try it. public AfricanLion(bool male, int weight) : base(male, weight) {}
public override string ToString() { return string.Format( "(AfricanLion, male: {0}, weight: {1})", this.Male, this.Weight); }
// ... } |
Ако коментираме или изтрием реда ":base(male, weight);", класът AfricanLion няма да се компилира. Опитайте.
Извикването на конструктор на базов клас става извън тялото на конструктора. Идеята е полетата на базовия клас да бъдат инициализирани преди да започнем да инициализираме полета в класа-наследник, защото може те да разчитат на някое поле от базовия клас. |
Модификатори на достъп на членове на класа при наследяване
Да си припомним - в главата "Дефиниране на класове" разгледахме основните модификатори на достъпа. За членовете на един клас (методи, свойства, член-променливи) бяха разгледани public, private, internal. Всъщност има още два модификатора - protected и internal protected. Ето какво означават те:
- protected дефинира членове на класа, които са невидими за ползвателите на класа (тези, които го инстанцират и използват), но са видими за класовете наследници
- protected internal дефинира членове на класа, които са едновременно internal, тоест видими за ползвателите в цялото асембли, но едновременно с това са и protected - невидими за ползвателите на класа (извън асемблито), но са видими за класовете наследници (дори и тези извън асемблито).
Когато се наследява един базов клас:
- Всички негови public и protected, protected internal членове (методи, свойства и т.н.) са видими за класа наследник.
- Всички негови private методи, свойства и член-променливи не са видими за класа наследник.
- Всички негови internal членове са видими за класа наследник само ако базовият клас и наследникът са в едно и също асембли.
Ето един пример, с който ще демонстрираме нивата на видимост при наследяване:
Felidae.cs |
/// <summary> /// Latin word for "cat" /// </summary> public class Felidae { private bool male;
public Felidae() : this(true) {}
public Felidae(bool male) { this.male = male; }
public bool Male { get { return male; } set { this.male = value; } } } |
Ето как изглежда и класът Lion:
Lion.cs |
public class Lion : Felidae { private int weight;
public Lion(bool male, int weight) : base(male) { // Compiler error – base.male is not visible in Lion base.male = male; this.weight = weight; }
// ... } |
Ако се опитаме да компилираме този пример, ще получим грешка, тъй като private променливата male от класа Felidae не е достъпна от класа Lion:
Класът Object
Появата на обектно-ориентираното програмиране де факто става популярно с езика C++. В него често се налага да се пишат класове, които трябва да работят с обекти от всякакъв тип. В C++ този проблем се решава по начин, който не се смята за много обектно-ориентиран стил (чрез използване на указатели от тип void).
Архитектите на .NET поемат в друга посока. Те създават клас, който всички други класове пряко или косвено да наследяват и до който всеки обект може да бъде преобразуван. В този клас е удобно да бъдат сложени важни методи и тяхната имплементация по подразбиране. Този клас се нарича Object.
В .NET всеки клас, който не наследява друг клас изрично, наследява системния клас System.Object по подразбиране. За това се грижи компилаторът. Всеки клас, който наследява друг клас, наследява индиректно Object от него. Така всеки клас явно или неявно наследява Object и има в себе си всички негови методи и полета.
Благодарение на това свойство всеки обект може да бъде преобразуван до Object. Типичен пример за ползата от неявното наследяване на Object е при колекциите, които разгледахме в главите за структури от данни. Списъчните структури (например System.Collections.ArrayList) могат да работят с всякакви обекти, защото ги разглеждат като инстанции на класа Object.
Специално за колекциите и работата с различни типове обекти има т.нар. Generics (обяснени подробно в главата "Дефиниране на класове"). Тя позволява създаването на типизирани класове – например колекция, която работи само с обекти от тип Lion. |
.NET, стандартните библиотеки и Object
В .NET има много предварително написани класове (вече разгледахме доста от тях в главите за колекции, текстови файлове и символни низове). Тези класове са част от .NET платформата – навсякъде, където има .NET, ги има и тях. Тези класове се наричат обща система от типове – Common Type System (CTS).
.NET е една от първите платформи, която идва с такъв богат набор от предварително написани класове. Голяма част от тях работят с Object, за да могат да бъдат използвани на възможно най-много места.
В .NET има и доста библиотеки, които могат да се добавят допълнително и съвсем логично се наричат просто клас-библиотеки или още външни библиотеки.
Object, upcasting, downcasting – пример
Нека разгледаме класа Object с един пример:
ObjectExample.cs |
public class ObjectExample { public static void main() { AfricanLion africanLion = new AfricanLion(true, 80); // Implicit casting object obj = africanLion; } } |
В този пример преобразувахме един AfricanLion в Object. Тази операция се нарича upcasting и е позволена, защото AfricanLion е непряк наследник на класа Object.
Тук е моментът да споменем, че ключовите думи string и object са само компилаторни трикове и всъщност при компилация се заменят съответно със System.String и System.Object. |
Нека продължим примера:
ObjectExample.cs |
// ...
AfricanLion africanLion = new AfricanLion(true, 80); // Implicit casting object obj = africanLion;
try { // Explicit casting AfricanLion castedLion = (AfricanLion) obj; } catch (InvalidCastException ice) { Console.WriteLine("obj cannot be downcasted to AfricanLion"); } |
В този пример преобразувахме един Object в AfricanLion. Тази операция се нарича downcasting и е позволена само ако изрично укажем към кой тип искаме да преминем, защото Object е родител на AfricanLion и не е ясно дали променливата obj е от тип AfricanLion. Ако не е, се хвърля InvalidCastException.
Методът Object.ТoString()
Един от най-използваните методи, идващи от класа Object, е ToString(). Той връща текстово представяне на обекта. Всеки обект има такъв метод и следователно има текстово представяне. Този метод се използва, когато отпечатваме обект чрез Console.WriteLine(…).
Object.ToString() – пример
Ето един пример, в който извикваме метода ToString():
ToStringExample.cs |
public class ToStringExample { public static void Main() { Console.WriteLine(new object()); Console.WriteLine(new Felidae(true)); Console.WriteLine(new Lion(true, 80)); } } |
Резултатът е:
System.Object Chapter_20_OOP.Felidae Chapter_20_OOP.Lion Press any key to continue . . . |
Тъй като Lion не пренаписва (override) метода ToString(), в конкретния случай се извиква имплементацията от базовия клас. Felidae също не пренаписва този метод, следователно се извиква имплементацията, наследена от класа System.Object. В резултата, който виждаме по-горе, се съдържа именното пространство (namespace) на обекта и името на класа.
Пренаписване на ТoString() – пример
Нека сега ви покажем колко полезно може да е пренаписването на метода ToString(), наследено от System.Object:
AfricanLion.cs |
public class AfricanLion : Lion { // ...
public override string ToString() { return string.Format( "(AfricanLion, male: {0}, weight: {1})", this.Male, this.Weight); }
// ... } |
В горния код използваме String.Format(…) метода, за да форматираме резултата по подходящ начин. Ето как можем след това да извикваме пренаписания метод ToString():
OverrideExample.cs |
public class OverrideExample { public static void Main() { Console.WriteLine(new object()); Console.WriteLine(new Felidae(true)); Console.WriteLine(new Lion(true, 80)); Console.WriteLine(new AfricanLion(true, 80)); } } |
Резултатът е:
System.Object Chapter_20_OOP.Felidae Chapter_20_OOP.Lion (AfricanLion, male: True, weight: 80) Press any key to continue . . . |
Забележете, че извикването на ToString() става скрито. Когато на метода WriteLine() подадем някакъв обект, този обект първо се преобразува до символен низ чрез метода му ToString() и след това се отпечатва в изходния поток. Така при печатане на конзолата няма нужда изрично да преобразуваме обектите до символен низ.
Виртуални методи и ключовите думи override и new
Трябва да укажем изрично на компилатора, че искаме нашият метод да пренаписва друг. За целта се използва ключовата дума override. Забележете какво се случва ако я премахнем:
Нека си направим един експеримент и използваме ключовата дума new вместо override:
public class AfricanLion : Lion { // ...
public new string ToString() {
return string.Format( "(AfricanLion, male: {0}, weight: {1})", this.Male, this.Weight); }
// ... }
public class OverrideExample { public static void Main() { AfricanLion africanLion = new AfricanLion(true, 80); string asAfricanLion = africanLion.ToString(); string asObject = ((object)africanLion).ToString(); Console.WriteLine( asAfricanLion ); Console.WriteLine( asObject ); } } |
Резултатът е следният:
(AfricanLion, male: True, weight: 80) Chapter_20_OOP.AfricanLion Press any key to continue . . . |
Забелязваме, че когато направим upcast на AfricanLion към object се извиква имплементацията Object.ToString(). Тоест когато използваме ключовата дума new създаваме нов метод, който скрива стария и можем да го извикаме само чрез upcast.
Какво става, ако в горния пример върнем думата override? Вижте сами резултата:
(AfricanLion, male: True, weight: 80) (AfricanLion, male: True, weight: 80) Press any key to continue . . . |
Изненадващо, нали? Оказва се, че когато пренапишем метода (override) дори и с upcast не можем да извикаме старата имплементация. Това е, защото вече не съществуват 2 метода ToString() за класа AfricanLion, а само един – пренаписан.
Метод, който може да бъде пренаписан, се нарича виртуален метод. В .NET методите по подразбиране не са такива. Ако желаем един метод да може да бъде пренаписан, можем да укажем това с ключовата дума virtual в декларацията на метода.
Изричното указване на компилатора, че искаме да пренапишем метод от базов клас (с override), е защита против грешки. Ако случайно сбъркаме една буква от името на метода, който се опитваме да пренапишем, или типовете на неговите параметри, компилаторът веднага ще ни съобщи за грешката. Той ще разбере, че нещо не е наред, като не може да намери метод със същата сигнатура в някой от базовите класове.
Виртуалните методи са подробно обяснени в частта, отнасяща се за полиморфизма.
Транзитивност при наследяването
В математиката транзитивност означава прехвърляне на взаимоотношения. Нека вземем операцията "по-голямо". Ако А>В и В>С, то можем да заключим, че А>С. Това означава, че релацията "по-голямо" (>) е транзитивна, защото може еднозначно да бъде определено дали А е по-голямо от С или обратното.
Ако клас Lion наследява клас Felidae, а клас AfricanLion наследява клас Lion, това индиректно означава, че AfricanLion наследява Felidae. Следователно AfricanLion също има свойство Male, което е дефинирано във Felidae. Това полезно свойство позволява определена функционалност да бъде описана в най-подходящия за нея клас.
Транзитивност – пример
Ето един пример, който демонстрира транзитивността при наследяване:
TransitivityExample.cs |
public class TransitivityExample { public static void Main() { AfricanLion africanLion = new AfricanLion(true, 15); // Property defined in Felidae bool male = africanLion.Male; africanLion.Male = true; } } |
Заради транзитивността на наследяването можем да сме сигурни, че всички класове имат ToString() и другите методи на Object без значение кой клас наследяват.
Йерархия на наследяване
Ако тръгнем да описваме всички големи котки, рано или късно се стига до сравнително голяма група класове, които се наследяват един друг. Всички тези класове, заедно с базовите такива, образуват йерархия от класове на големите котки. Такива йерархии могат да се опишат най-лесно чрез клас-диаграми. Нека разгледаме какво е това "клас-диаграма".
Клас-диаграми
Клас-диаграмата е един от няколко вида диаграми дефинирани в UML. UML (Unified Modeling Language) е нотация за визуализация на различни процеси и обекти, свързани с разработката на софтуер. За UML се говори по-подробно в секцията за нотацията UML. Сега, нека ви разкажем малко за клас-диаграмите, защото те се използват, за да описват визуално йерархиите от класове, наследяването и вътрешността на самите класове.
В клас-диаграмите има възприети правила класовете да се рисуват като правоъгълници с име, атрибути (член-променливи) и операции (методи), а връзките между тях се обозначават с различни видове стрелки.
Накратко ще обясним два термина от UML, за по-ясно разбиране на примерите. Единият е генерализация (generalization). Генерализация е обобщаващо понятие за наследяване на клас или имплементация на интерфейс (за интерфейси ще обясним след малко). Другият термин се нарича асоциация (association). Например "Лъвът има лапи", където Лапа е друг клас.
Генерализация и асоциация са двата най-основни начина за преизползване на код. |
Един клас от клас диаграма – пример
Ето как изглежда една примерна клас-диаграма на един клас:
Класът е представен като правоъгълник, разделен на 3 части, разположени една под друга. В най-горната част е дефинирано името на класа. В следващата част след него са атрибутите (термин от UML) на класа (в .NET се наричат член-променливи и свойства). Най-отдолу са операциите (в UML) или методите (в .NET). Плюсът/минусът в началото указват дали атрибутът/операцията са видими (+ означава public) или невидими (- означава private). Protected членовете се означават със символа #.
Клас-диаграма – генерализация – пример
Ето пример за клас диаграма, показваща генерализация:
В този пример стрелките означават генерализация или наследяване.
Асоциации
Асоциациите представляват връзки между класовете. Те моделират взаимоотношения. Могат да дефинират множественост (1 към 1, 1 към много, много към 1, 1 към 2, ..., и много към много).
Асоциация много към много (many-to-many) се означава по следния начин:
Асоциация много към много (many-to-many) по атрибут се означава по следния начин:
В този случай има свързващи атрибути, които показват в кои променливи се държи връзката между класовете.
Асоциация едно към много (one-to-many) се означава така:
Асоциация едно към едно (one-to-one) се означава така:
От диаграми към класове
От клас-диаграмите най-често се създават класове. Диаграмите улесняват и ускоряват дизайна на класовете на един софтуерен проект.
От горната диаграма можем директно да създадем класове.
Ето класа Capital:
Capital.cs |
public class Capital { } |
Ето и класа Country:
Country.cs |
public class Country {
/// <summary> /// Country's capital. /// </summary> private Capital capital;
// ...
public Capital Capital { get { return capital; } set { this.capital = value; } }
// ... } |
Агрегация
Агрегацията е специален вид асоциация. Тя моделира връзката "цяло / част". Агрегат наричаме родителския клас. Компоненти наричаме агрегираните класове. В единия край на агрегацията има празен ромб:
Композиция
Запълнен ромб означава композиция. Композицията е агрегация, при която компонентите не могат да съществуват без агрегата (родителя):
Абстракция (Abstraction)
Следващият основен принцип от обектно-ориентираното програмиране, който ще разгледаме, е "абстракция". Абстракцията означава да работим с нещо, което знаем как да използваме, но не знаем как работи вътрешно. Например имаме телевизор. Не е нужно да знаем как работи телевизорът отвътре, за да го ползваме. Нужно ни е само дистанционното и с малък брой бутони (интерфейс на дистанционното) можем да гледаме телевизия.
Същото се получава и с обектите в ООП. Ако имаме обект Лаптоп и той се нуждае от процесор, просто използваме обекта Процесор. Не знаем (или по-точно не се интересуваме) как той смята вътрешно. За да го използваме, е достатъчно да извикваме метода сметни() с подходящи параметри.
Абстракцията е нещо, което правим всеки ден. Това е действие, при което игнорираме всички детайли, които не ни интересуват от даден обект, и разглеждаме само детайлите, които имат значение за проблема, който решаваме. Например в хардуера съществува абстракция "устройство за съхранение на данни", което може да бъде твърд диск, USB memory stick, флопи диск или CD-ROM устройство. Всяко от тях работи вътрешно по различен начин, но от гледна точка на операционната система и на програмите в нея те се използват по еднакъв начин – на тях се записват файлове и директории. В Windows имаме Windows Explorer и той умее да работи по еднакъв начин с всички устройства, независимо дали са твърд диск или USB stick. Той работи с абстракцията "устройство за съхранение на данни" (storage device) и не се интересува как точно данните се четат и пишат. За това се грижат драйверите за съответните устройства. Те се явяват конкретни имплементации на интерфейса "устройство за съхранение на данни".
Абстракцията е една от най-важните концепции в програмирането и в ООП. Тя ни позволява да пишем код, който работи с абстрактни структури от данни (например списък, речник, множество и други). Имайки абстрактния тип данни, ние можем да работим с него през неговия интерфейс, без да се интересуваме от имплементацията му. Например можем да запазим във файл всички елементи на списък, без да се интересуваме дали той е реализиран с масив, чрез свързан списък или по друг начин. Този код остава непроменен, когато работим с различни конкретни типове данни. Дори можем да пишем нови типове данни (които се появяват на по-късен етап) и те да работят с нашата програма, без да я променяме.
Абстракцията ни позволява и нещо много важно – да дефинираме интерфейс на нашите програми, т.е. да дефинираме всички задачи, които тази програма може да извърши, както и съответните входни и изходни данни. Така можем да направим няколко по-малки програми, всяка от които да извършва някаква по-малка задача. Като прибавим това към факта, че можем да работим с абстрактни данни, ни дава голяма гъвкавост при свързването на тези по-малки програми в една по-голяма и ни дава повече възможности за преизползване на код. Тези малки подпрограми се наричат компоненти. Този начин на писане на програми намира широко приложение в практиката, защото ни позволява не само да преизползваме обекти, а дори цели подпрограми.
Абстракция – пример за абстрактни данни
Ето един пример, в който дефинираме конкретен тип данни "африкански лъв", но след това го използваме по абстрактен начин – чрез абстракцията "лъв". Тази абстракция не се интересува от детайлите на всички видове лъвове.
AbstractionExample.cs |
public class AbstractionExample { public static void Main() { Lion lion = new Lion(true, 150); Felidae bigCat1 = lion;
AfricanLion africanLion = new AfricanLion(true, 80); Felidae bigCat2 = africanLion; } } |
Интерфейси
В езика C# интерфейсът е дефиниция на роля (на група абстрактни действия). Той дефинира какво поведение трябва да има един обект, без да указва как точно се реализира това поведение.
Един обект може да има много роли (да имплементира много интерфейси) и ползвателите му могат да го използват от различни гледни точки.
Например един обект Човек може да има ролите Военен (с поведение "стреляй по противника"), Съпруг (с поведение "обичай жена си"), Данъкоплатец (с поведение "плати си данъка"). Всеки човек обаче имплементира това поведение по различен начин: Иван си плаща данъците навреме, Георги – не навреме, Петър – въобще не ги плаща.
Някой може да попита защо най-базовият за всички обекти клас Object не е всъщност интерфейс. Причината е, че тогава всеки клас щеше да трябва да имплементира една малка, но много важна, група методи, а това би отнемало излишно време. Оказва се, че и не всеки клас има нужда от специфична реализация на Object.GetHashCode(), Object.Equals(…), Object.ToString(), тоест имплементацията по подразбиране върши работа в повечето случаи. От класа Object не е нужно да се пренапише (повторно имплементира) никой метод, но ако се наложи, това може да се направи. Пренаписването на методи е обяснено в детайли в секцията за виртуални методи.
Интерфейси – ключови понятия
В интерфейса може да има само декларации на методи и константи.
Сигнатура на метод (method signature) е съвкупността от името на метода + описание на параметрите (тип и последователност). В един клас/интерфейс всички методи трябва да са с различни сигнатури и да не съвпадат със сигнатури на наследени методи.
Декларация на метод (method declaration) е съвкупността от връщания тип на метода + сигнатурата на метода. Връщаният тип е просто за яснота какво ще върне метода.
Това, което идентифицира един метод, е неговата сигнатура. Връщаният тип не е част нея. Причината е, че ако два метода се различават само по връщания тип (например два класа, които се наследяват един друг), то не може еднозначно да се идентифицира кой метод трябва да се извика. |
Имплементация на клас/метод (class/method implementation) е тялото със сорс код на класа/метода. Най често е заключено между скобите { и }. При методите се нарича още тяло на метод.
Интерфейси – пример
Интерфейсът в .NET се дефинира с ключовата думичка interface. В него може да има само декларации на методи, както и статични променливи (за константи например). Ето един пример за интерфейс:
Reproducible.cs |
public interface Reproducible<T> where T:Felidae { T[] Reproduce(T mate); } |
За шаблонни типове (Generics) сме говорили в главата "Дефиниране на класове". Интерфейсът, който сме написали, има един метод от тип Т (Т трябва да наследява Felidae) и връща масив от Т.
Ето как изглежда и класът Lion, който имплементира интерфейса Reproducible:
Lion.cs |
public class Lion : Felidae, Reproducible<Lion> { // ...
Lion[] Reproducible<Lion>.Reproduce(Lion mate) { return new Lion[]{new Lion(true, 12), new Lion(false, 10)}; } } |
Името на интерфейса се записва в декларацията на класа (първия ред) и се специфицира шаблонният клас.
Можем да укажем метод на кой интерфейс имплементираме, като му напишем името:
Lion[] Reproducible<Lion>.Reproduce(Lion mate) |
В интерфейса методите само се декларират, имплементацията е в класа, който имплементира интерфейса – Lion.
Класът, който имплементира даден интерфейс, трябва да имплементира всеки метод от него. Изключение – ако класът е абстрактен, тогава може да имплементира нула, няколко или всички методи. Всички останали методи се имплементират в някой от класовете наследници.
Абстракция и интерфейси
Най-добрият начин да се реализира абстракция е да се работи с интерфейси. Един компонент работи с интерфейси, които друг имплементира. Така подмяната на втория компонент няма да се отрази на първия, стига новият компонент да имплементира старите интерфейси. Интерфейсът се нарича още договор (contract). Всеки компонент, имплементирайки един интерфейс, спазва определен договор (сигнатурата на методите). Така два компонента, стига да спазват правилата на договора, могат да общуват един с друг, без да знаят как работи другата страна.
Примери за важни интерфейси от Common Type System (CTS) са System.Collections.Generic.IList<T> и System.Collections.Generic. ICollection<T>. Всички стандартни колекции имплементират тези интерфейси и различните компоненти си прехвърлят различни имплементации (масиви или свързани списъци, хеш-таблици, червено-черни дървета и др.) винаги под общ интерфейс.
Колекциите са един отличен пример на обектно-ориентирана библиотека с класове и интерфейси, при която се използват много активно всички основни принципи на ООП: абстракция, наследяване, капсулация и полиморфизъм.
Кога да използваме абстракция и интерфейси?
Отговорът на този въпрос е: винаги, когато искаме да постигнем абстракция на данни или действия, чиято имплементация по-късно може да се подмени. Код, който комуникира с друг код чрез интерфейси е много по-издръжлив срещу промени, отколкото код, написан срещу конкретни класове. Работата през интерфейси е често срещана и силно препоръчвана практика – едно от основните правила за писане на качествен код.
Кога да пишем интерфейси?
Винаги е добра идея да се използват интерфейси, когато се предоставя функционалност на друг компонент. В интерфейса се слага само функционалността (като декларация), която другите трябва да виждат.
Вътрешно в една програма/компонент интерфейсите могат да се използват за дефиниране на роли. Така един обект може да се използва от много класове чрез различните му роли.
Капсулация (Encapsulation)
Капсулацията е един от основните принципи на обектно-ориентираното програмиране. Тя се нарича още "скриване на информацията" (information hiding). Един обект трябва да предоставя на ползвателя си само необходимите средства за управление. Една Секретарка ползваща един Лаптоп знае само за екран, клавиатура и мишка, а всичко останало е скрито. Тя няма нужда да знае за вътрешността на Лаптопа, защото не й е нужно и може да оплеска нещо. Тогава част от свойствата и методите остават скрити за нея.
Изборът какво е скрито и какво е публично видимо е на този, който пише класа. Когато програмираме, трябва да дефинираме като private (скрит) всеки метод или поле, които не искаме да се ползват от друг клас.
Капсулация – примери
Ето един пример за скриване на методи, които не е нужно да са известни на потребителя, а се ползват вътрешно само от автора на класа. Първо дефинираме абстрактен клас Felidae, който дефинира публичните операции на котките (независимо какви точно котки имаме):
Felidae.cs |
public class Felidae { public virtual void Walk() { // ... }
// ... } |
Ето как изглежда класът Lion:
Lion.cs |
public class Lion : Felidae, Reproducible<Lion> { // ...
private Paw frontLeft; private Paw frontRight; private Paw bottomLeft; private Paw bottomRight;
private void MovePaw(Paw paw) { // ... }
public override void Walk() { this.movePaw(frontLeft); this.movePaw(frontRight); this.movePaw(bottomLeft); this.movePaw(bottomRight); }
// ... } |
Публичният метод Walk() извиква 4 пъти някакъв друг скрит (private) метод. Така базовият клас е кратък – само един метод. Имплементацията обаче извиква друг метод, също част от имплементацията, но скрит за ползвателя на класа. Така класът Lion не разкрива публично информация за това как работи вътрешно и това му дава възможност на по-късен етап да промени имплементацията си без останалите класове да разберат (и да имат нужда от промяна).
Полиморфизъм (Polymorphism)
Следващият основен принцип от обектно-ориентираното програмиране е "полиморфизъм". Полиморфизмът позволява третирането на обекти от наследен клас като обекти от негов базов клас. Например големите котки (базов клас) хващат жертвите си (метод) по различен начин. Лъвът (клас наследник) ги дебне, докато Гепардът (друг клас-наследник) просто ги надбягва.
Полиморфизмът дава възможността да третираме произволна голяма котка просто като голяма котка и да кажем "хвани жертвата си", без значение каква точно е голямата котка.
Полиморфизмът може много да напомня на абстракцията, но в програмирането се свързва най-вече с пренаписването (override) на методи в наследените класове с цел промяна на оригиналното им поведение, наследено от базовия клас. Абстракцията се свързва със създаването на интерфейс на компонент или функционалност (дефиниране на роля). Пренаписването на методи ще разгледаме в детайли след малко.
Абстрактни класове
Какво става, ако искаме да кажем, че класът Felidae е непълен и само наследниците му могат да имат инстанции? Това става с ключовата дума abstract пред името на класа и означава, че класът не е готов и не може да бъде инстанциран. Такъв клас се нарича абстрактен клас. А как да укажем коя точно част от класа не е пълна? Това отново става с ключовата дума abstract пред името на метода, който трябва да бъде имплементиран. Този метод се нарича абстрактен метод и не може да притежава имплементация, а само декларация.
Всеки клас, който има поне един абстрактен метод, трябва да бъде абстрактен. Логично, нали? Обратното, обаче не е в сила. Възможно е да дефинираме клас като абстрактен дори когато в него няма нито един абстрактен метод.
Абстрактните класове са нещо средно между клас и интерфейс. Те могат да дефинират обикновени методи и абстрактни методи. Обикновените методи имат тяло (имплементация), докато абстрактните методи са празни (без имплементация) и са оставени да бъдат реализирани от класовете-наследници.
Абстрактен клас – примери
Да разгледаме един пример за абстрактен клас:
Felidae.cs |
/// <summary> /// Latin word for "cat" /// </summary> public abstract class Felidae { // ...
protected void Hide() { // ... }
protected void Run() { // ... }
public abstract bool CatchPray(object pray); } |
Забележете в горния пример как нормалните методи Hide() и Run() имат тяло, а абстрактният метод CatchPray() няма тяло. Забележете, че методите са protected.
Ето как изглежда имплементацията:
Lion.cs |
public class Lion : Felidae, Reproducible<Lion> { protected void Ambush() { // ... }
public override bool CatchPray(object pray) { base.Hide(); this.Ambush(); base.Run(); // ... return false; } } |
Ето още един пример за абстрактно поведение, реализирано чрез абстрактен клас и полиморфно извикване на абстрактен метод. Първо дефинираме абстрактния клас Animal:
Animal.cs |
public abstract class Animal { public void PrintInformation() { Console.WriteLine("I am {0}.", this.GetType().Name); Console.WriteLine(GetTypicalSound()); }
protected abstract String GetTypicalSound(); } |
Дефинираме и класа Cat, който наследява абстрактния клас Animal и дефинира имплементация за абстрактния метод GetTypicalSound():
Cat.cs |
public class Cat : Animal { protected override String GetTypicalSound() { return "Miaoooow!"; } } |
Ако изпълним следната програма:
public class AbstractClassExample { public static void Main() { Animal cat = new Cat(); cat.PrintInformation(); } } |
... ще получим следния резултат:
I am Cat. Miaoooow! Press any key to continue . . . |
В примера методът PrintInformation() от абстрактния клас свършва своята работа като разчита на резултата от извикването на абстрактния метод GetTypicalSound(), който се очаква да бъде имплементиран по различен начин за различните животни (различните наследници на класа Animal). Различните животни издават различни звуци, но отпечатването на информация за животно е една и съща функционалност за всички животни и затова е изнесена в базовия клас.
Чист абстрактен клас
Абстрактните класове, както и интерфейсите не могат да се инстанцират. Ако се опитате да създадете инстанция на абстрактен клас, ще получите грешка по време на компилация.
Понякога даден клас може да бъде деклариран като абстрактен дори и да няма нито един абстрактен метод, просто, за да се забрани директното му използване, без да се създава инстанция на негов наследник. |
Чист абстрактен клас (pure abstract class) е абстрактен клас, който няма нито един имплементиран метод, както и нито една член променлива. Много напомня на интерфейс. Основната разлика е, че един клас може да имплементира много интерфейси и наследява само един клас (бил той и чист абстрактен клас).
В началото при съществуването на множествено наследяване не е имало нужда от интерфейси. За да бъде заместено, се е наложило да се появят интерфейсите, които да носят многото роли на един обект.
Виртуални методи
Метод, който може да се пренапише в клас наследник, се нарича виртуален метод (virtual method). Методите в .NET не са виртуални по подразбиране. Ако искаме да бъдат виртуални, ги маркираме с ключовата дума virtual. Тогава клас-наследник може да декларира и дефинира метод със същата сигнатура.
Виртуалните методи са важни за пренаписването на методи (method overriding), което е в сърцето на полиморфизма.
Виртуални методи – пример
Имаме клас, наследяващ друг, като и двата имат общ метод. И двата метода пишат на конзолата. Ето как изглежда класът Lion:
Lion.cs |
public class Lion : Felidae, Reproducible<Lion> { public override void CatchPray(object pray) { Console.WriteLine("Lion.CatchPray"); } } |
Ето как изглежда и класът AfricanLion:
AfricanLion.cs |
public class AfricanLion : Lion { public override void CatchPray(object pray) { Console.WriteLine("AfricanLion.CatchPray"); } } |
Правим три опита за създаване на инстанции и извикване на метода CatchPray.
VirtualMethodsExample.cs |
public class VirtualMethodsExample { public static void Main() { { Lion lion = new Lion(true, 80); lion.CatchPray(null); // Will print "Lion.CatchPray" }
{ AfricanLion lion = new AfricanLion(true, 120); lion.CatchPray(null); // Will print "AfricanLion.CatchPray" }
{ Lion lion = new AfricanLion(false, 60); lion.CatchPray(null); // Will print "AfricanLion.CatchPray", because // the variable lion has value of type AfricanLion } } } |
В последния опит ясно се вижда как всъщност се извиква пренаписаният метод, а не базовият. Това се случва, защото се проверява кой всъщност е истинският клас, стоящ зад променливата, и се проверява дали той има имплементиран (пренаписан) този метод.
Пренаписването на методи се нарича още: препокриване (подмяна) на виртуален метод.
Както виртуалните, така и абстрактните методи могат да бъдат препокривани. Абстрактните методи всъщност представляват виртуални методи без конкретна имплементация. Всички методи, които са дефинирани в даден интерфейс са абстрактни и следователно виртуални, макар и това да не е дефинирано изрично.
Виртуални методи и скриване на методи
В горния пример имплементацията на базовия клас остана скрита и неизползвана. Ето как можем да ползваме и нея като част от новата имплементация (в случай че не искаме да подменим, а само да допълним старата имплементация).
Ето как изглежда и класът AfricanLion:
AfricanLion.cs |
public class AfricanLion : Lion { public override void CatchPray(object pray) { Console.WriteLine("AfricanLion.CatchPray"); Console.WriteLine("calling base.CatchPray"); Console.Write("\t"); base.CatchPray(pray); Console.WriteLine("...end of call."); } } |
В този пример при извикването на AfricanLion.catchPray(…) ще се изпишат 3 реда на конзолата:
AfricanLion.CatchPray calling base.CatchPray Lion.CatchPray ...end of call. |
Разликата между виртуални и невиртуални методи
Някой може да попита каква е разликата между виртуалните и невиртуалните методи.
Виртуални методи се използват, когато очакваме наследяващите класове да променят/допълват/изменят дадена функционалност. Например методът Object.ToString() позволява наследяващите класове да променят както си искат имплементацията. И тогава дори когато работим с един обект не директно, а чрез upcast до object пак използваме пренаписаната имплементация на виртуалните методи.
Виртуалните методи са ключова способност на обектите когато говорим за абстракция и работа с абстрактни типове.
Запечатването на методи (sealed) се прави, когато разчитаме на дадена функционалност и не желаем тя да бъде променяна. Разбрахме, че методите по принцип са запечатани. Но ако искаме един виртуален метод от базов клас да запечатаме в класа наследник, използваме override sealed.
Класът string няма нито един виртуален метод. Всъщност наследяването на string е забранено изцяло с ключовата дума sealed в декларацията на класа. Ето част от декларацията на string и object (триеточието в квадратните скоби указва пропуснат код, който не е релевантен):
namespace System { [...] public class Object { [...] public Object();
[...] public virtual bool Equals(object obj); [...] public static bool Equals(object objA, object objB); [...] public virtual int GetHashCode(); [...] public Type GetType(); [...] protected object MemberwiseClone(); [...] public virtual string ToString(); }
[...] public sealed class String : [...] { [...] public String(char* value);
[...] public int IndexOf(string value); [...] public string Normalize(); [...] public string[] Split(params char[] separator); [...] public string Substring(int startIndex); [...] public string ToLower(CultureInfo culture);
[...] } } |
Кога да използваме полиморфизъм?
Отговорът на този въпрос е прост: винаги, когато искаме да предоставим възможност имплементацията на даден метод да бъде подменен в клас-наследник. Добро правило е да се работи с възможно най-базовия клас или направо с интерфейс. Така промените върху използваните класове се отразяват в много по-малка степен върху класовете, които ние пишем. Колкото по-малко знае една програма за обкръжаващите я класове, толкова по-малко промени (ако въобще има някакви) трябва да претърпи тя.
Свързаност на отговорностите и функционално обвързване (cohesion и coupling)
Термините cohesion и coupling са неразривно свързани с ООП. Те допълват и дообясняват някои от принципите, които описахме до момента. Нека се запознаем с тях.
Свързаност на отговорностите (cohesion)
Понятието cohesion (свързаност на отговорностите) показва до каква степен различните задачи и отговорности на една програма или един компонент са свързани помежду си, т.е. колко фокусиранa е програмата в решаването на една единствена задача. Разделя се на силна свързаност (strong cohesion) и слаба свързаност (weak cohesion).
Силна свързаност на отговорностите (strong cohesion)
Когато кохезията (cohesion) е силна, това показва, че отговорностите и задачите на една единица код (метод, клас, компонент, подпрограма) са свързани помежду си и се стремят да решат общ проблем. Това е нещо, към което винаги трябва да се стремим. Strong cohesion е типична характеристика на висококачествения софтуер.
Силна свързаност за клас
Силна свързаност на отговорностите (strong cohesion) в един клас означава, че този клас описва само един субект. По-горе споменахме, че един субект може да има много роли (Петър е военен, съпруг, данъкоплатец). Всички тези роли се описват в един и същ клас. Силната свързаност означава, че класът решава една задача, един проблем, а не много едновременно. Клас, който прави много неща едновременно, е труден за разбиране и поддръжка. Представете си клас, който реализира едновременно хеш-таблица, предоставя функции за печатане на принтер, за пращане на e-mail и за работа с тригонометрични функции. Какво име ще дадем на този клас? Ако се затрудняваме в отговора на този въпрос, това означава, че нямаме силна свързаност на отговорностите (cohesion) и трябва да разделим класа на няколко по-малки, всеки от които решава само една задача.
Силна свързаност за клас – пример
Като пример за силна свързаност на отговорности можем да дадем класа System.Math. Той изпълнява една единствена задача – предоставя математически изчисления и константи:
- Sin(), Cos(), Asin()
- Sqrt(), Pow(), Exp()
- Math.PI, Math.E
Силна свързаност за метод
Един метод е добре написан, когато изпълнява само една задача и я изпълнява добре. Метод, който прави много неща, свързани със съвсем различни задачи, има лоша кохезия и трябва да се раздели на няколко по-прости метода, които решават само една задача. И тук стои въпросът какво име ще дадем на метод, който търси прости числа, чертае 3D графика на екрана, комуникира по мрежата и печата на принтер справки, извлечени от база данни. Такъв метод има лоша кохезия и трябва да се раздели логически на няколко метода.
Слаба свързаност на отговорностите (weak cohesion)
Слаба свързаност се наблюдава при методи, които вършат по няколко задачи. Тези методи трябва да приемат няколко различни групи параметри, за да извършат различните задачи. Понякога това налага несвързани логически данни да се обединяват за точно такива методи. Използването на слаба кохезия (weak cohesion) е вредно и трябва да се избягва!
Слаба свързаност на отговорностите – пример
Ето един пример за клас, който има слаба свързаност на отговорностите (weak cohesion):
public class Magic { public void PrintDocument(Document d) { ... } public void SendEmail(string recipient, string subject, string text) { ... } public void CalculateDistanceBetweenPoints( int x1, int y1, int x2, int y2) { ... } } |
Добри практики за свързаност на отговорностите
Съвсем логично силната свързаност е "добрият" начин на писане на код. Понятието се свързва с по-прост и по-ясен сорс код – код, който по-лесно се поддържа и по-лесно се преизползва (поради по-малкия на брой задачи, които той изпълнява).
Обратно, при слаба свързаност всяка промяна е бомба със закъснител, защото може да засегне друга функционалност. Понякога една логическа задача се разпростира върху няколко модула и така промяната й е по-трудоемка. Преизползването на код също е трудно, защото един компонент върши няколко несвързани задачи и за да се използва отново, трябва да са на лице точно същите условия, което трудно може да се постигне.
Функционално обвързване (coupling)
Функционално обвързване (coupling) описва най-вече до каква степен компонентите / класовете зависят един от друг. Дели се на функционална независимост (loose coupling) и силна взаимосвързаност (tight coupling). Функционалната независимост обикновено идва заедно със слабата свързаност на отговорностите и обратно.
Функционална независимост (loose coupling)
Функционалната независимост (loose coupling) се характеризира с това, че единиците код (подпрограма / клас / компонент) общуват с други такива през ясно дефинирани интерфейси (договори) и промяната в имплементацията на един компонент не се отразява на другите, с които той общува. Когато пишете програмен код, не трябва да разчитате на вътрешни характеристики на компонентите (специфично поведение, неописано в интерфейсите).
Договорът трябва да е максимално опростен и да дефинира единствено нужните за работата на този компонент поведения, като скрива всички ненужни детайли.
Функционалната независимост е характеристика на кода, към която трябва да се стремите. Тя е една от отличителните черти на качествения програмен код.
Loose coupling – пример
Ето един пример, в който имаме функционална независимост между класовете и методите:
class Report { public bool LoadFromFile(string fileName) {…} public bool SaveToFile(string fileName) {…} }
class Printer { public static int Print(Report report) {…} }
class Example { public static void Main() { Report myReport = new Report(); myReport.LoadFromFile("DailyReport.xml"); Printer.Print(myReport); } } |
В този пример никой клас и никой метод не зависи от останалите. Методите зависят само от параметрите, които им се подават. Ако някой метод ни потрябва в следващ проект, лесно ще можем да го извадим и използваме отново.
Силна взаимосвързаност (tight coupling)
Силна взаимосвързаност имаме при много входни параметри и изходни параметри и при използване на неописани (в договора) характеристики на друг компонент (например зависимост от статични полета в друг клас). При използване на много т. нар. контролни променливи, които оказват какво да е поведението със същинските данни. Силната взаимосвързаност между два или повече метода, класа или компонента означава, че те не могат да работят независимо един от друг и че промяната в един от тях ще засегне и останалите. Това води до труден за четене код и големи проблеми при поддръжката му.
Tight coupling – пример
Ето един пример, в който имаме силна взаимосвързаност между класовете и методите:
class MathParams { public static double operand; public static double result; }
class MathUtil { public static void Sqrt() { MathParams.result = CalcSqrt(MathParams.operand); } }
class SpaceShuttle { public static void Main() { MathParams.operand = 64; MathUtil.Sqrt(); Console.WriteLine(MathParams.result); } } |
Такъв код е труден за разбиране и за поддръжка, а възможността за грешки при използването му е огромна. Помислете какво се случва, ако друг метод, който извиква Sqrt(), подава параметрите си през същите статични променливи operand и result.
Ако се наложи в следващ проект да използваме същата функционалност за извличане на корен квадратен, няма да можем просто да си копираме метода Sqrt(), а ще трябва да копираме класовете MathParams и MathUtil заедно с всичките им методи. Това прави кода труден за преизползване.
Всъщност горният код е пример за лош код по всички правила на процедурното и обектно-ориентираното програмиране и ако се замислите, сигурно ще се сетите за още поне няколко неспазени препоръки, които сме ви давали до момента.
Добри практики за функционално обвързване
Най-честият и препоръчителен начин за извикване на функционалност на един добре написан модул е през интерфейси, така функционалността може да се подменя, без клиентите на този код да трябва да се променят. Жаргонният израз за това е "програмиране срещу интерфейси".
Интерфейсът най-често описва "договора", който този модул спазва. Добрата практика е да не се разчита на нищо повече от описаното в този договор. Използването на вътрешни класове, които не са част от публичния интерфейс на един модул не е препоръчително, защото тяхната имплементация може да се подмени без това да подмени договора (за това вече споменахме в секцията "Абстракция").
Добра практика е методите да са гъвкави и да са готови да работят с всички компоненти, които спазват интерфейса им, а не само с определени такива (тоест да имат неявни изисквания). Последното би означавало, че тези методи очакват нещо специфично от компонентите, с които могат да работят. Добра практика е също всички зависимости да са ясно описани и видими. Иначе поддръжката на такъв код става трудна (пълно е с подводни камъни).
Добър пример за strong cohesion и loose coupling са класовете в System.Collections и System.Collections.Generic. Класовете за работа с колекции имат силна кохезия. Всеки от тях решава една задача и позволява лесна преизползваемост. Тези класове притежават и другата характеристика на качествения програмен код: loose coupling. Класовете, реализиращи колекциите, са необвързани един с друг. Всеки от тях работи през строго дефиниран интерфейс и не издава детайли за своята имплементация. Всички методи и полета, които не са от интерфейса, са скрити, за да се намали възможността за обвързване на други класове с тях. Методите в класовете за колекции не зависят от статични променливи и не разчитат на никакви входни данни, освен вътрешното си състояние и подадените им параметри. Това е добрата практика, до която рано или късно всеки програмист достига като понатрупа опит.
Код като спагети (spaghetti code)
Спагети код е неструктуриран код с неясна логика, труден за четене, разбиране и за поддържане. Това е код, в който последователността е нарушена и объркана. Това е код, който има weak cohesion и tight coupling. Този код се свързва се със спагети, защото също като тях е оплетен и завъртян. Като дръпнеш един спагет (т.е. един клас или метод), цялата чиния спагети може да се окаже, оплетена в него (т. е. промяна на един метод или клас води до още десетки други промени поради силната зависимост между тях). Спагети кодът е почти невъзможно да се преизползва, защото няма как да отделиш тази част от него, която върши работа.
Спагети кодът се получава, когато сте писали някакъв код, след това сте го допълнили, след това изискванията са се променили и вие сте нагодили кода към тях, след това пак са се пременили и т.н. С времето спагетите се оплитат все повече и повече и идва момент, в който всичко трябва да се пренапише от нулата.
Cohesion и coupling в инженерните дисциплини
Ако си мислите, че принципите за strong cohesion и loose coupling се отнасят само за програмирането, дълбоко се заблуждавате. Това са здрави инженерни принципи, които ще срещнете в строителството, в машиностроенето, в електрониката и на още хиляди места.
Да вземем за пример един твърд диск:
Той решава една единствена задача, нали? Твърдият диск решава задачата за съхранение на данни. Той не охлажда компютъра, не издава звуци, няма изчислителна сила и не се ползва като клавиатура. Той е свързан с компютъра само с 2 кабела, т.е. има прост интерфейс за достъп и не е обвързан с другите периферни устройства. Твърдият диск работи самостоятелно и другите устройства не се интересуват от това точно как работи. Централния процесор му казва "чети" и той чете, след това му казва "пиши" и той пише. Как точно го прави е скрито вътре в него. Различните модели могат да работят по различен начин, но това си е техен проблем. Виждате, че един твърд диск притежава strong cohesion, loose coupling, добра абстракция и добра капсулация. Така трябва да реализирате и вашите класове – да вършат една задача, да я вършат добре, да се обвързват минимално с другите класове (или въобще да не се обвързват, когато е възможно), да имат ясен интерфейс и добра абстракция и да скриват детайлите за вътрешната си работа.
Ето един друг пример: представете си какво щеше да стане, ако на дънната платка на компютъра бяха запоени процесорът, твърдият диск, CD-ROM устройството и клавиатурата. Това означава, че като ви се повреди някой клавиш от клавиатурата, ще трябва да изхвърлите на боклука целия компютър. Виждате, че при tight coupling и weak cohesion хардуерът не може да работи добре. Същото се отнася и за софтуера.
Обектно-ориентирано моделиране (OOM)
Нека приемем, че имаме да решаваме определен проблем или задача. Този проблем идва обикновено от реалния свят. Той съществува в дадена реалност, която ще наричаме заобикаляща го среда.
Обектно-ориентираното моделиране (ООМ) е процес, свързан с ООП, при който се изваждат всички обекти, свързани с проблема, който решаваме (създава се модел). Изваждат се само тези техни характеристики, които са свързани с решаването на конкретния проблем. Останалите се игнорират. Така вече си създаваме нова реалност, която е опростена версия на оригиналната (неин модел), и то такава, че ни позволява да си решим проблема или задачата.
Например, ако моделираме система за продажба на билети, за един пътник важни характеристики биха могли да бъдат неговото име, неговата възраст, дали ползва намаление и дали е мъж, или жена (ако продаваме спални места). Пътникът има много други характеристики, които не ни интересуват, примерно какъв цвят са му очите, кой номер обувки носи, какви книги харесва или каква бира пие.
При моделирането се създава опростен модел на реалността с цел решаване на конкретната задача. При обектно-ориентираното моделиране моделът се прави със средствата на ООП: чрез класове, атрибути на класовете, методи в класовете, обекти, взаимоотношения между класовете и т.н. Нека разгледаме този процес в детайли.
Стъпки при обектно-ориентираното моделиране
Обектно-ориентираното моделиране обикновено се извършва в следните стъпки:
- Идентификация на класовете.
- Идентификация на атрибутите на класовете.
- Идентификация на операциите върху класовете.
- Идентификация на връзките между класовете.
Ще разгледаме кратък пример, с който ще ви покажем как могат да се приложат тези стъпки.
Идентификация на класовете
Нека имаме следната извадка от заданието за дадена система:
На потребителя трябва да му е позволено да описва всеки продукт по основните му характеристики, включващи име и номер на продукта. Ако бар-кодът не съвпада с продукта, тогава трябва да бъде генерирана грешка на екрана за съобщения. Трябва да има дневен отчет за всички транзакции, специфицирани в секция 9. |
Ето как идентифицираме ключовите понятия:
На потребителя трябва да му е позволено да описва всеки продукт по основните му характеристики, включващи име и номер на продукта. Ако бар-кодът не съвпада с продукта, тогава трябва да бъде генерирана грешка на екрана за съобщения. Трябва да има дневен отчет за всички транзакции, специфицирани в секция 9. |
Току-що идентифицирахме класовете, които ще ни трябват. Имената на класовете са съществителните имена в текста, най-често нарицателни в единствено число, например Студент, Съобщение, Лъв. Избягвайте имена, които не идват от текста, примерно: СтраненКлас, АдресКойтоИмаСтудент.
Понякога е трудно да се прецени дали някой предмет или явление от реалния свят трябва да бъде клас. Например адресът може да е клас Address или символен низ. Колкото по-добре проучим проблема, толкова по-лесно ще решим кое трябва да е клас. Когато даден клас стане прекалено голям и сложен, той трябва да се декомпозира на няколко по-малки класове.
Идентификация на атрибутите на класовете
Класовете имат атрибути (характеристики), например: класът Student има име, учебно заведение и списък от курсове. Не всички характеристики са важни за софтуерната система. Например: за класа Student цветът на очите е несъществена характеристика. Само съществените характеристики трябва да бъдат моделирани.
Идентификация на операциите върху класовете
Всеки клас трябва да има ясно дефинирани отговорности – какви обекти или процеси от реалния свят представя, какви задачи изпълнява. Всяко действие в програмата се извършва от един или няколко метода в някой клас. Действията се моделират с операции (методи).
За имената на методите се използват глагол + съществително. Примери: PrintReport(), ConnectToDatabase(). Не може веднага да се дефинират всички методи на даден клас. Дефинираме първо най-важните методи – тези, които реализират основните отговорности на класа. С времето се появяват още допълнителни методи.
Идентификация на връзките между класовете
Ако един студент е от определен факултет и за задачата, която решаваме, това е важно, тогава студент и факултет са свързани. Тоест класът Факултет има списък от Студенти. Тези връзки наричаме още асоциации (спомнете си секцията "клас-диаграми").
Нотацията UML
UML (Unified Modeling Language) бе споменат в секцията за наследяване. Там разгледахме клас-диаграмите. UML нотацията дефинира още няколко вида диаграми. Нека разгледаме накратко някои от тях.
Use case диаграми (случаи на употреба)
Използват се при извличане на изискванията за описание на възможните действия. Актьорите (actors) представят роли (типове потребители).
Случаите на употреба (use cases) описват взаимодействие между актьорите и системата. Use case моделът е група use cases – предоставя пълно описание на функционалността на системата.
Use case диаграми – пример
Ето как изглежда една use case диаграма:
Актьорът е някой, който взаимодейства със системата (потребител, външна система или примерно външната среда). Актьорът има уникално име и евентуално описание.
Един use case описва една от функционалностите на системата. Той има уникално име и е свързан с актьори. Може да има входни и изходни условия. Най-често съдържа поток от действия (процес). Може да има и други изисквания.
Sequence диаграми
Използват се при моделиране на изискванията за описание на процеси. За по-добро описание на use case сценариите. Позволяват описание на допълнителни участници в процесите. Използват се при дизайна за описание на системните интерфейси.
Sequence диаграми – пример
Ето как изглежда една sequence диаграма:
Класовете се представят с колони. Съобщенията (действията) се представят чрез стрелки. Участниците се представят с широки правоъгълници. Състоянията се представят с пунктирана линии.
Съобщения – пример
Посоката на стрелката определя изпращача и получателя на съобщението. Хоризонталните прекъснати линии изобразяват потока на данните:
Statechart диаграми
Statechart диаграмите описват възможните състояния на даден процес и възможните преходи между тях. Представляват краен автомат:
Activity диаграми
Представляват специален тип statechart диаграми, при които състоянията са действия. Показват потока на действията в системата:
Шаблони за дизайн
Достатъчно време след появата на обектно-ориентираната парадигма се оказва, че съществуват множество ситуации, които се появяват често при писането на софтуер. Например клас, който трябва да има само една инстанция в рамките на цялото приложение.
Появяват се шаблоните за дизайн (design patterns) – популярни решения на често срещани проблеми от обектно-ориентираното моделиране. Част от тях са най-добре обобщени в едноименната книга на Ерих Гама "Design Patterns: Elements of Reusable Object Oriented Software" (ISBN 0-201-63361-2).
Това е една от малкото книги на компютърна тематика, които остават актуални 15 години след издаването си. Шаблоните за дизайн допълват основните принципи на ООП с допълнителни добре известни решения на добре известни проблеми. Добро място за започване на разучаването им е статията за тях в Уикипедия: http://en.wikipedia.org/wiki/ Design_pattern (computer science).
Шаблонът Singleton
Това е най-популярният и използван шаблон. Позволява на определен клас да има само една инстанция и дефинира откъде да се вземе тази инстанция. Типични примери са класове, които дефинират връзка към единствени неща (виртуалната машина, операционна система, мениджър на прозорците при графично приложение, файлова система), както и класовете от следващия шаблон (factory).
Шаблонът Singleton – пример
Ето примерна имплементация на шаблона Singleton:
Singleton.cs |
public class Singleton { // Single instance private static Singleton instance;
// Initialize the single instance static Singleton() { instance = new Singleton(); }
// The property for taking the single instance public static Singleton Instance { get { return instance; } }
// Private constructor – protects direct instantialion private Singleton() { } } |
Имаме скрит конструктор, за да ограничим инстанциите (най-долу). Имаме статична променлива, която държи единствената инстанция. Инициализираме я еднократно в статичния конструктор на класа. Свойството за вземане на инстанцията най-често се казва Instance.
Шаблонът може да претърпи много оптимизации, например т.нар. "мързеливо инициализиране" (lazy init) на единствената променлива за спестяване на памет, но това е класическата му форма.
Шаблонът Factory Method
Factory method е друг много разпространен шаблон. Той е предназначен да "произвежда" обекти. Инстанцирането на определен обект не се извършва директно, а се прави от factory метода. Това позволява на factory метода да реши коя конкретна инстанция да създаде. Решението може да зависи от външната среда, от параметър или от някаква системна настройка.
Шаблонът Factory Method – пример
Factory методите капсулират създаването на обекти. Това е полезно, ако процесът по създаването е много сложен – например зависи от настройки в конфигурационните файлове или от данни въведени от потребителя.
Нека имаме клас, който съдържа графични файлове (png, jpeg, bmp, …) и създава умалени откъм размер техни копия (т.нар. thumbnails). Поддържат се различни формати представени от клас за всеки от тях:
public class Thumbnail { // ... }
public interface Image { Thumbnail CreateThumbnail(); }
public class GifImage : Image { public Thumbnail CreateThumbnail() { // ... create thumbnail ... return gifThumbnail; } }
public class JpegImage : Image { public Thumbnail CreateThumbnail() { // ... create thumbnail ... return jpegThumbnail; } } |
Ето го и класът-албум на изображения:
public class ImageCollection { private IList<Image> images;
public ImageCollection(IList<Image> images) { this.images = images; }
public IList<Thumbnail> CreateThumbnails() { IList<Thumbnail> thumbnails = new List<Thumbnail>(images.Count);
foreach (Image th in images) { thumbnails.Add(th.CreateThumbnail()); } return thumbnails; } } |
Клиентът на програмата може да изисква умалени копия на всички изображения в албума:
public class Example { public static void Main() { IList<Image> images = new List<Image>();
images.Add(new JpegImage()); images.Add(new GifImage());
ImageCollection imageRepository = new ImageCollection(images);
Console.WriteLine(imageRepository.CreateThumbnails()); } } |
Други шаблони
Съществуват десетки други добре известни шаблони за дизайн, но няма да се спираме подробно на тях. По-любознателните читатели могат да потърсят за "Design Patterns" в Интернет и да разберат за какво служат и как се използват шаблони като: Abstract Factory, Prototype, adapter, composite, Façade, Command, Iterator, Observer и много други. Ако продължите да се занимавате с .NET по-сериозно, ще се убедите, че цялата стандартна библиотека (CTS) е конструирана върху принципите на ООП и използва много активно класическите шаблони за дизайн.
Упражнения
1. Нека е дадено едно училище. В училището име класове от ученици. Всеки клас има множество от учители. Всеки учител преподава множество от предмети. Учениците имат име и уникален номер в класа. Класовете имат уникален текстов идентификатор. Учителите имат име. Предметите имат име, брой на часове и брой упражнения. Както учителите, така и студентите са хора. Вашата задача е да моделирате класовете (в контекста на ООП) заедно с техните атрибути и операции, дефинирате класовата йерархия и създайте диаграма с Visual Studio.
2. Дефинирайте клас Human със свойства "собствено име" и "фамилно име". Дефинирайте клас Student, наследяващ Human, който има свойство "оценка". Дефинирайте клас Worker, наследяващ Human, със свойства "надница" и "изработени часове". Имплементирайте и метод "изчисли надница за 1 час", който смята колко получава работникът за 1 час работа, на базата на надницата и изработените часове. Напишете съответните конструктори и методи за достъп до полетата (свойства).
3. Инициализирайте масив от 10 студента и ги сортирайте по оценка в нарастващ ред. Използвайте интерфейса System.IComparable.
4. Инициализирайте масив от 10 работника и ги сортирайте по заплата в намаляващ ред.
5. Дефинирайте клас Shape със само един метод calculateSurface() и полета width и height. Дефинирайте два нови класа за триъгълник и правоъгълник, които имплементират споменатия виртуален метод. Този метод трябва да връща площта на правоъгълника (height*width) и триъгълника (height*width/2). Дефинирайте клас за кръг с подходящ конструктор, при когото при инициализация и двете полета (height и width) са с еднаква стойност (радиуса), и имплементирайте виртуалния метод за изчисляване на площта. Направете масив от различни фигури и сметнете площта на всичките в друг масив.
6. Имплементирайте следните обекти: куче (Dog), жаба (Frog), котка (Cat), котенце (Kitten), котарак (Tomcat). Всички те са животни (Animal). Животните се характеризират с възраст (age), име (name) и пол (gender). Всяко животно издава звук (виртуален метод на Animal).
Направете масив от различни животни и за всяко изписвайте на конзолата името, възрастта и звука, който издава.
7. Изтеглете си някакъв инструмент за работа с UML и негова помощ генерирайте клас диаграма на класовете от предходната задача.
8. Дадена банка предлага различни типове сметки за нейните клиенти: депозитни сметки, сметки за кредит и ипотечни сметки. Клиентите могат да бъдат физически лица или фирми. Всички сметки имат клиент, баланс и месечен лихвен процент. Депозитните сметки дават възможност да се внасят и теглят пари. Сметките за кредит и ипотечните сметки позволяват само да се внасят пари. Всички сметки могат да изчисляват стойността на лихвата си за даден период (в месеци). В общия случай това става като се умножи броят_на_месеците * месечния_лихвен_процент. Кредитните сметки нямат лихва за първите три месеца ако са на физически лица. Ако са на фирми – нямат лихва за първите два месеца. Депозитните сметки нямат лихва ако техният баланс е положителен и по-малък от 1000. Ипотечните сметки имат ½ лихва за първите 12 месеца за фирми и нямат лихва за първите 6 месеца за физически лица. Вашата задача е да напишете обектно- ориентиран модел на банковата система чрез класове и интерфейси. Трябва да моделирате класовете, интерфейсите, базовите класове и абстрактните операции и да имплементирате съответните изчисления за лихвите.
9. Прочетете за шаблона "Abstract Factory" и го имплементирайте.
Решения и упътвания
1. Задачата е тривиална. Просто следвайте условието и напишете кода.
2. Задачата е тривиална. Просто следвайте условието и напишете кода.
3. Имплементирайте IComparable в Student и оттам просто сортирайте списъка.
4. Задачата е като предната.
5. Имплементирайте класовете, както са описани в условието на задачата.
6. Изписването на информацията можете да го имплементирате във виртуалния метод System.Object.ToString(). За да принтирате съдържанието на целия масив, можете да ползвате цикъл с foreach.
7. Можете да намерите списък с UML инструменти от следния адрес: http://en.wikipedia.org/wiki/List_of_UML_tools.
8. Имплементирайте класовете както са описани в условието на задачата.
9. Можете да прочетете за шаблона "abstract factory" от Wikipedia: http://en.wikipedia.org/wiki/Abstract_factory_pattern.
Демонстрации (сорс код)
Изтеглете демонстрационните примери към настоящата глава от книгата: Принципи-на-обектно-ориентираното-програмиране-(ООП)-Демонстрации.zip.
Дискусионен форум
Коментирайте книгата и задачите в нея във: форума на софтуерната академия.
Коментирай
Трябва да сте влезнали, за да коментирате.