Mostenire, interfete si clase abstracte
Mostenire
Unul din principiile OOP este mosteria, este o metoda simpla de a va extinde clasele deja scrise cu alte functionalitati sau sa generalizati functionalitati pentru mai multe clase. Cand folosim mostenire spunem ca o clasa copil/derivata extinde (extends), deriva (derives) sau mosteneste (inherits) o clasa parinte/de baza si atunci o clasa copil este de tipul clasei parinte intotdeuna.
/* Aceasta este o clasa de baza, o vom folosi pentru a arata cum se poate extinde
* si cum se pot suprascrie metode.
*/
public class Animal
{
public string Name { get; init; }
public Animal(string name)
{
Name = name;
}
/* Metodele virtuale sunt metode care au implementare implicita
* dar care poate fi suprascrisa de clasa copil.
*/
public virtual string MakeSound() => $"{Name} makes a sound!";
/* Metodele care nu sunt virtuale sunt de asemenea prezente pentru clasa derivata
* dar nu se suprascriu, ele se pot doar ascunde (shadow) de clasele derivate.
*/
public void Eats() => Console.WriteLine("{0} is eating...", Name);
}
// O clasa deriva alta prin ":" urmat de numele clasei mostenite.
public class Duck : Animal
{
/* Constructorul clasei derivate trebuie sa apeleze un constructor al clasei parinte.
* Implicit acel constructor este cel fara parametri, altfel trebuie apelat ca mai jos
* unul prin cuvantul cheie "base"
*/
public Duck(string name) : base(name)
{
}
/* Aici suprasciem cu "override" metoda virtuala cu o noua implementare.
* Putem apela si metoda clasei parinte prin cuvantul cheie "base"
*/
public override string MakeSound() => $"{base.MakeSound()} Quacks!";
// Putem adauga noi functionalitati la clasa derivata fata de clasa mostenita.
public void Fly() => Console.WriteLine("{0} is flying...", Name);
}
public class Dog : Animal
{
public Dog(string name) : base(name)
{
}
// Aici suprasciem cu "override" metoda virtuala cu o noua implementare.
public override string MakeSound() => $"{Name} barks!";
/* Aici ascundem cu "new" metoda parintelui.
* Noua implementare este folosita doar atat timp cat instanta e de tipul clasei derivate.
*/
public new void Eats() => Console.WriteLine("{0} is eating meat...", Name);
// Putem adauga noi functionalitati la clasa derivata fata de clasa mostenita.
public void Run() => Console.WriteLine("{0} is running...", Name);
}
// Fiecare obiect care mosteneste Animal este de acest tip si poate sa i se faca cast implicit.
Animal obj1 = new Animal("Dave");
Animal obj2 = new Dog("Richard");
Animal obj3 = new Duck("James");
Animal[] animals = { obj1, obj2, obj3 };
foreach (var animal in animals)
{
// Putem vedea ca putem folosi metode ale clasei parinte inclusiv cele virtuale pentru ca au o implementare.
Console.WriteLine("Testing methods for {0} as parent class.", animal.Name);
animal.Eats();
Console.WriteLine(animal.MakeSound());
/* Aici putem sa tratam fiecare instanta in functie de tipul ei cel mai specific,
* se poate observa diferenta in comportament pentru metoda Eats() careia i se face shadowing in clasa derivata.
*/
Console.WriteLine("Testing methods for {0} as actual class.", animal.Name);
switch (animal)
{
case Dog dog:
dog.Eats();
Console.WriteLine(dog.MakeSound());
dog.Run();
break;
case Duck duck:
duck.Eats();
Console.WriteLine(duck.MakeSound());
duck.Fly();
break;
default:
animal.Eats();
Console.WriteLine(animal.MakeSound());
break;
}
}
Dupa cum se poate vedea, avem doua concepte aici in ce priveste mostenirea metodelor (se aplica doar la metode non-statice, cele statice nu pot fi suprascrise), suprascriere (overriding) si umbrire/ascundere (shadowing).
La supresciere, o metoda care exista in clasa parinte ca fiind virtuala (virtual) este suprascria de catre clasa copil prin cuvantul cheie override. Metoda virtuala are implementare dar este doar cea implicita, o clasa copil este libera sa suprascie metoda sau nu iar la runtime indiferent daca instanta este declarata ca clasa parinte sau cea copil este luata in considerare metoda supracrisa a instantei.
La umbrire, clasa copil da o noua implementare metodei non-virtuale prin cuvantul cheie new, acesta ascunde sau umbreste metoda clasei parinte daca instanta este declarata ca fiind cea derivata, altfel daca instanta este declarata ca fiind clasa parinte atunci se apeleaza metoda parintelui nu a clasei copil. De obicei, umbrirea este folosita doar in caz extrem daca nu exista alta solutie pentru cazul particular.
Interfete
De retinut ca o clasa poate mosteni direct doar o singura clasa, nu mai multe, desi clasa care e mostenita poate avea la randul sau un parinte. Implicit orice clasa mosteneste clasa object in C#. Motivul pentru care nu se permite mostenire directa multipla este ca ar fi greu pentru compilator sau pentru programator sa-si dea seama ce camp sau metoda este referit daca mai multe clase parinte au unele definitii de campuri si metode la fel. Un exemplu de problema este problema diamantului.
Sa presupunem ca clasele B si C mostenesc clasa A si clasa D mosteneste clasele B si C. Daca acest lucru ar fi permis si clasa A are campuri sau metode care sunt suprascrise n-o sa se stie care camp sau metoda trebuie sa fie folosita si va duce la erori. Acesta este un motiv pentru care se poate mostenii doar un singur obiect, la fel e si in alte limbaje cum ar fi Java. Problema enuntata este o problema numita problema diamantului si este doar un caz de problema care poate aparea la mostenire multipla.
Ca exemplu, sa presupunem ca vreti sa procesati datele din mai multe obiecte dar fiecare obiect este de tip diferit insa rezultatul este de acelasi tip. De exemplu, aveti o lista de forme geometrice si vreti sa calculati aria, va ganditi ca pentru fiecare forma aveti cate o clasa cu cate o metoda .ComputeArea() dar fiecare tip de obiect calculeaza aria diferit.
Solutia este sa folosim interfete, o interfata este un tip care nu are implementare si nu se poate instantia direct dar precizeaza ce metode si proprietati trebuie sa aiba acel tip. Astfel putem sa avem mai multe obiecte care au aceiasi metoda dar implementari diferite. Metodele declarate in interfete sunt numite in literatura de specialitate, metode abstracte sau pur virtuale. Clasele nu mai extind interfetele, aici se zice ca clasele implementeaza interfetele.
Important de stiut este ca putem avea obiect care sunt declarate ca fiind de tipul interfetei dar interfetele nu pot fi niciodata instantiate, doar clasele care implementeaza interfetele pot fi instantiale. O clasa care implementeaza interfata este obligata sa aiba implementate toate metodele din acea interfata.
// Delaram o interfata ca o clasa dar inlocuind "class" cu "interface".
public interface IShape
{
/* Putem declara definitia metodelor fara implementare.
* Metodele aici, pentru ca nu au implementare, sunt abstracte sau pur virtuale.
*/
public double ComputeArea();
// La fel putem avea si proprietati readonly in interfete.
public string Name { get; }
}
// Declararea implementarii unei interfete se face la fel ca la mostenire de clasa.
public class Circle : IShape
{
public double Radius { get; init; }
public Circle(double radius) => Radius = radius;
public double ComputeArea() => Math.PI * Radius * Radius;
public string Name => nameof(Circle);
}
public class Rectangle : IShape
{
public double Width { get; }
public double Height { get; }
public Rectangle(double width, double height)
{
Width = width;
Height = height;
}
public double ComputeArea() => Height * Width;
public string Name => nameof(Rectangle);
}
public class Triangle : IShape
{
public double SideA { get; }
public double SideB { get; }
public double SideC { get; }
public Triangle(double sideA, double sideB, double sideC)
{
SideA = sideA;
SideB = sideB;
SideC = sideC;
}
public double ComputeArea()
{
var s = (SideA + SideB + SideC) / 2;
return Math.Sqrt(s * (s - SideA) * (s - SideB) * (s - SideC));
}
public string Name => nameof(Triangle);
}
// Fiecare forma care implementeaza interfata IShape este de acest tip
IShape[] shapes = { new Circle(10), new Rectangle(7, 8), new Triangle(3, 4, 5) };
Console.WriteLine("The computed areas are: ");
foreach (var shape in shapes)
{
Console.WriteLine("{0} {1}", shape.Name, shape.ComputeArea());
}
Prin interfete putem astfel face abstractie de implementare si sa ne folosim doar de interfata ca sa facem computatii in mod generic. Pentru ca interfetele n-au implementare o clasa poate extinde oricate interfete pe langa o clasa.
Clase abstracte
Pe langa interfete putem avea si clase abstracte, acestea pot fi mostenite doar una de catre o alta clasa la fel ca cele normale doar ca clasele abstracte nu pot fi instantiate la fel ca interfetele. Clasele abstracte sunt bune daca aveti logica generica dar anumite detalii de implementare depind de cazuri specifice, astfel cazurile specifice pot fi metode si proprietati abstracte care sa fie implementate de clasele derivate iar logica generica sa fie metode si proprietati implementate in clasa abstracta care sa fie partajate de clasele derivate pentru a reduce cantitatea de cod.
Mai jos este un exemplu pentru folosirea claselor abstracte pentru implementarea a doua solutii diferite de stocare de date.
// Declaram o interfata care sa fie folosita de factory.
public interface IStorage
{
public void SaveValue(string key, string value);
public string? GetValue(string key);
public void AddValue(string key, string value);
}
/* Clasa abstracta poate sau nu sa implementeze o interfata.
* Aici folosim clasa astracta pentru ca o metoda poate fi implementata din alte metode
* si pentru a reduce codul implementam acea metoda.
*/
public abstract class AbstractStorage : IStorage
{
// Metodele care nu se implementeaza sunt declarate cu "abstract".
public abstract void SaveValue(string key, string value);
public abstract string? GetValue(string key);
/* Clasa abstracta poate avea metode implementate, daca se implementeaza
* o interfata trebuie fie ca medele sa fie declarate ca fiind abstracte fie
* se fie implementate.
*/
public void AddValue(string key, string value)
{
var oldValue = GetValue(key);
SaveValue(key, oldValue != null ? $"{oldValue} {value}" : value);
}
}
// Daca folosim cuvantul cheie "sealed", aceasta clasa nu mai poate fi derivata mai departe.
public sealed class FileStorage : AbstractStorage
{
private const string FilePath = "./Storage.txt";
// Trebuie folosit "override" pe metodele abstracte din clasa abstracta
public override void SaveValue(string key, string value)
{
// ...
}
public override string? GetValue(string key)
{
// ...
}
}
public class InMemoryStorage : AbstractStorage
{
private readonly Dictionary<string, string> _cache = new();
public override void SaveValue(string key, string value) => _cache[key] = value;
public override string? GetValue(string key) => _cache.TryGetValue(key, out var value) ? value : default;
}
O aplicabilitate pentru interfete este implementarea unui design pattern numit factory pattern. Un factory este o clasa care instantiaza la cerere mai multe tipuri. Aceasta este un exemplu simplu de cum se poate implementa un factory. Desigur, in practica acest factory poate sa fie configurat inainte pentru a parametriza crearea instantelor.
// Cu acest enum putem cere la factory sa producem instante de ISorage
public enum StorageTypeEnum
{
File,
InMemory
}
// Putem face o clasa care sa functioneze ca fabrica pentru crearea intantelor unor interfete.
public static class StorageFactory
{
// Avem o metoda care la cerere ne da diferite instante dand doar o valoare care specifica tipul instantei.
public static IStorage GetStorage(StorageTypeEnum type) =>
type switch
{
StorageTypeEnum.File => new FileStorage(),
StorageTypeEnum.InMemory => new InMemoryStorage(),
_ => throw new ArgumentOutOfRangeException(nameof(type), type, "Cannot produce this type!")
};
}
Interfete vs. Clase abstracte vs. Clase concrete
- Folositi interfete cand aveti nevoie doar de o lista de metode si proprietati pentru mai multe tipuri.
- Folositi clase abstracte daca aveti o parte din logica clasei comuna pentru toate tipurile derivate si o implementati o data.
- Nu folositi clase abstracte pe post de interfete, sunt considerabile diferentele la cum sunt vazute de runtime pentru ca interfetele pot fi folosite si ca identificatori pentru diverse biblioteci.
- In clase abstracte se pot declara si implementari implicite cu cuvantul cheie "virtual" si pot fi suprascrise in clasele derivate cu cuvantul cheie "override" daca este necesar, folositi acestea daca aveti cazuri de aceasta natura in loc de interfete.
- In aproape toate cazurile aveti nevoie fie de interfete fie de clase abstracte pentru mostenire/extindere, nu derivati clase concrete decat daca e absolut necesar si aveti nevoie sa adagugati functionalitati peste clasa mostenita. O sa vedem ca preferabil ar fi de folosit in aceste cazuri polimorfism static, clase partiale sau metode de extensie.
- Folositi clase normale pentru mostenire daca intentionati sa extindeti obiecte cu mai multe date si doar vreti sa reduceti cod duplicat.
Trebuie sa mentionam ca in foarte mult cazuri se poate evita si ar fi preferabil de folosit in loc de mostenire/extidere de interfete alte metode cum ar fi genericitate, dar depinde de caz si trebuie vazut in mod concret de ce este nevoie.