Objekt Orienterad Programmering med C# Del 4: Arv och Polymorfism

Introduktion

I denna del den 4:e i min serie om Objekt Orienterad Programmering med C# ska vi fortsätta och dyka ner i arv och polymorfism.

Vi har redan tagit oss an arv och arvs hierarkier, vi ska nu i denna modul kika lite närmare på följande:

  • Åtkomst hantering för klasser och metoder
    • Vi kommer att göra detta ur perspektivet arv och säkerhet
  • Constructor metoder vi arv
    • Hur hanterar vi constructor metoder i härledda klasser?
  • Vi kommer även att definiera begrepp som Downcasting och Upcasting
  • Dessutom kommer vi att titta lite närmare på begrepp som Boxing och Unboxing
  • Vi kommer att diskutera vad abstrakta klasser är för något
  • Vi kommer att lära oss om åsidåsättning av metoder i en arvshierarki
  • Avslutningsvis kommer vi att gå igenom låsta klasser och metoder
    • Så kallade sealed classes.

Så låt oss komma igång

Åtkomst kontroll (Access Modifier)

Nu tänker ni väl, att det har vi ju redan gjort. Det stämmer men bara delvis vi har gått igenom åtkomst skydd som public och private. Vi kommer nu att gå igenom dem i lite mer detalj och varför de är så viktiga.

Klasser som svarta lådor

Ett vanligt uttryck inom objekt orienterad programmering är "black box". Detta är ett uttryck som egentligen innebär att vi ska gömma allt som inte ska vara tillgängligt utifrån klassen. Vi har redan diskuterat detta när vi gick igenom inkapsling av vårt tillstånd. Vi skapade egenskaper som automatisk skapade privata fält som representerade vårt tillstånd eller om vi så vill vår information som vi vill kunna hantera i klasserna. Enda sättet att få åtkomst till tillståndet/informationen var genom väl definierade egenskaper eller metoder.

Black box

Låt oss se på ett verkligt exempel. En Wifi router är något som de flesta har hemma för att kunna surfa på nätet. Det är en ganska komplex enhet men vi bryr oss inte om hur den fungerar internt(så länge den fungerar, annars köper vi en ny😁). Detta kan jämföras med de privata fälten och privata metoder i en klass.

Lamporna som lyser vackert på framsidan är det enda som vi egentligen behöver bry oss om, så länge som de lyser grönt. Detta är det publika gränssnittet som routern presenterar utåt.

På samma sätt ska vi tänka när vi designar våra klasser, vad för tillgång behövs utifrån för att utnyttja klassen. Endast det ska vi publicera resten ska gömmas inuti klassen. Genom att alltid tänka på detta viset så har vi skapat möjlighet att ändra hur klassen internt fungerar utan att bryta kopplingen mellan konsumerande klasser eller applikationerna.

Lite repetion avseende åtkomst hantering. Vi har följande åtkomst hanterare i C#

  • public
  • private
  • protected
  • internal
  • protected internal

public och private har vi redan gått igenom men repetion är aldrig fel

public

Om något är deklarerat med public så betyder det att det är tillgängligt för alla.

Exempel:

public class Invoice
{
    public void GeneratePDF(){}
}

var invoice = new Invoice();
invoice.GeneratePDF();

GeneratePDF är en metod som ska kunna anropas ifrån t ex en applikation för att generera en PDF representation av en faktura så att en kund ska kunna ladda hem den. Så här är public ett korrekt alternativ.

private

Endast tillgänglig inifrån klasserna.

Exempel:

public class Invoice
{
    private double CalculateReminderFee(){}
}

var invoice = new Invoice();
// Vi kan inte komma åt metoden.
invoice.CalculateReminderFee(); // Vi kommer att få ett kompileringsfel.

CalculateReminderFee är en intern metod eller en funktion som bara ska användas internt av klassen för att räkna fram en eventuell påminnelse avgift, innan vi kan producera fakturan för visning. Den ska inte kunna användas utanför klassen.

protected

Betyder att de metoder eller egenskaper som är deklarerade med protected endast är tillgängliga för klassen själv eller ifrån härledda klasser(barn klasser).

Exempel:

public class Invoice
{
    protected double CalculateReminderFee(){}
}

var invoice = new Invoice();
// Vi kan inte komma åt metoden.
invoice.CalculateReminderFee(); // Vi kommer att få ett kompileringsfel.

Metoden är fortfarande inte tillgänlig utifrån, men däremot så är den tillgänglig för klasser som ärver ifrån Invoice.

public class Reminder: Invoice {}

Se upp med protected, i och med att alla härledda klasser kan se och få tillgång till metoder eller egenskaper som är skyddade med protected. Så skapar vi ett onödigt och farligt beroende av de metoderna eller egenskaper i de härledda klasserna. Det blir mycket svårare att ändra implementationen(logiken) i metoderna eller egenskaper utan att eventuellt behöva ändra i de härledda klasserna.

Kom ihåg den svarta lådan! Vi vill att varje klass ska sköta sig självt och inte vara beroende av någon annan.

internal

Internal är en skyddsmekanism som vi använder på klasserna och inte på dess medlemmar. Internal innebär att klassen endast är tillgänglig för andra klasser i samma assembly.

En assembly är samlingen av alla filer och klasser i ett projekt som är kompilerade till en exekverbar fil eller till ett Dynamic Linked Library(DLL) paket.

protected internal

Detta skyddet är en kombination av protected och internal som innebär att klassen endast är tillgänglig inom en och samma assembly samt ifrån de klasser som ärver ifrån klassen.

Arv och constructor metoder

I vår genomgång av klasser gick vi igenom constructor metoder och dess syften. I den här delen ska vi gå igenom constructor metoder i sammanhanget arv.

Som vi såg angående constructor metoder i modulen klasser så såg vi att vi kunde delegera till constructor metoder inom klassen genom att använda nyckelordet this. När vi nu implementerar arv så dyker några nya utmaningar upp som vi måste hantera och förstå. Låt oss först se på ett exempel.

Vi går tillbaka och använder exemplet med fakturahantering.
Vi skapar tre olika klasser

  • Customer
  • Invoice
  • ReminderInvoice
public class Customer
{
  public int Id { get; set; }
  public string Name { get; set; } = "";
}

public class Invoice
{
  // Privata fält...
  private readonly Customer _customer;

  // Publika egenskaper...
  public string InvoiceNumber { get; set; } = "";
  public DateTime InvoiceDate { get; set; }
  public Double Total { get; set; }

  // Skapa en constructor metod som tar in en kund referens...
  public Invoice(Customer customer)
  {
    _customer = customer;
  }

  // Publika metoder...
  public void RenderInvoice()
  {
    var freight = CalculateFreight();
  }

  // Privata metoder för intern hantering i klassen...
  private int CalculateFreight()
  {
    return 0;
  }
}

public class ReminderInvoice : Invoice
{
  private readonly int _fee;

  public ReminderInvoice() { }
}

Koden i sig är inte speciellt komplex eller annorlunda mot vad vi redan sett. Det vi ska uppmärksamma är constructor metoden i klassen Invoice. Metoden tar som argument en instans av Customer klassen. Vi stöter nu på problem i vår ReminderInvoice klass. I och med att vi ärver ifrån Invoice klassen så måste vår härledda klass ta emot ett argument i sin constructor metod som är en instans av Customer klassen. Felet vi vår är följande:

Felmeddelandet är ganska självförklarande:
Vi har inte korrekt argument som överensstämmer med vad Invoice klassens constructor metod förväntar sig.
Ok, låt oss lägga till ett argument i constructor metoden i vår ReminderInvoice klass.

public ReminderInvoice(Customer customer){}

Det borde fixa det, tyvärr inte vi får fortfarande felet:

Det är fortfarande exakt samma fel så vad måste vi göra nu då? För att förstå lösningen på problemet måste vi förstå hur kompileringen av våra klasser går till.

Om vi drar oss till minnes när vi använde nyckelordet this för att delegera information mellan constructor metoder i en och samma klass. Kom vi fram till att instansieringen av ett objekt ifrån en klass skedde bakvänt.

Samma sak sker vid instansiering av barnklasser och dess föräldraklass. Först måste föräldraklassen skapas när den är skapad återgår exekvering till barnklassen för att fortsätta instansieringen.

Vad betyder detta för oss då?

Jo, vi måste använda ett nytt nyckelord för att kunna anropa föräldraklassen och det är nyckelordet base.

Så än en gång låt oss försöka genom att göra följande ändring i vår constructor metod i ReminderInvoice klassen.

public ReminderInvoice(Customer customer) : base(customer) { }

Nu försvann felet och vi har en korrekt instansiering av vår arvshierarki.

Observera syntaxen, vi använder ett kolon för att separera vår constructor metod ifrån anropet till vår föräldraklass(base).

Downcasting och Upcasting

När vi nu har gått igenom arv i detalj och hur vi hanterar arvshierarkier så ska vi nu titta på hur vi kan konvertera barn- och föräldra-klasser. Vi kommer att gå igenom och få förståelse för följande begrepp:

  • Konvertera från en barnklass till en föräldraklass
    • Kallas för upcasting
  • Konvertera från en föräldraklass till en barnklass
    • Kallas för downcasting
  • Nyckelorden is och as

Låt oss ta ett mycket enkelt exempel, detta är ett exempel som oftast finns i de allra flesta böcker om Objekt Orienterad Programmering.

public class Shape{}

public class Rectangle : Shape {}

Vi har två klasser här en klass Shape och en klass Rectangle som ärver ifrån Shape.

Låt oss se på Upcasting

Rectangle rect = new Rectangle();
Shape shape = rect;

Vad händer här?
Först skapar vi en ny instans av klassen Rectangle och sparar referensen i variabeln rect.
Sedan skapar vi en ny variabel av typen Shape och sätter värdet på shape till referensen rect.
Det vi gör nu är att konvertera vår referens från typen Rectangle till typen Shape. Detta sker automatiskt(implicit) vilket är helt ok i och med att Rectangle är en typ av Shape.

Nu låt oss se på Downcasting
Vi använder samma klasser här som i ovan exempel.

Rectangle rect = new Rectangle();
Shape shape = rect;

Rectangle newRect = (Rectangle)shape;

Vad händer här då?
Vi skapar en ny variabel newRect som är av typen Rectangle och sätter den till referensen shape som vi satte till referensen rect ovan. På grund av att när vi gör en upcast så är nu vår shape referens av typen Shape. Nu när vi vill konvertera en Shape referens ner till en Rectangle typ måste tvinga konverteringen, vi måste explicit göra om Shape till en Rectangle typ. Vad vi gör här är en downcast.

Här gäller det att se upp! Vi kan drabbas av ett exception när vi arbetar med downcast.

I vårt fall här är det inga problem för Rectangle är en typ av Shape så inga konstigheter, men om vi skulle försöka göra följande felaktig konvertering.

Invoice invoice = (Invoice)shape;

Så kommer vi att få ett exception kastat (InvalidCastException). Invoice är inte en typ av Shape!

För att undvika att få exceptions kastade kan vi använda oss av nyckelordet as. Vad vi utför med as är något jag kallar för defensiv programmering. Så låt oss se hur vi kan skriva om ovanstående försök till downcast.

object i = new Invoice();

var invoice = i as Shape;

if (invoice != null)
{
	Console.WriteLine("Det gick");
}
else
{
	Console.WriteLine("Det gick inte");
}

Vad som sker här är att först deklareras en variabel som en referens till en ny instans av Invoice klassen. Nästa steg är att deklarera en variabel invoice där vi nu vid tilldelning använder as för att kolla om vår variabel i är av typen Shape. Om den inte är det så kommer vi att få tillbaka ett null värde och det är det vi kontrollerar i if satsen.

Så i det här fallet fungerar inte downcast på grund av att Invoice inte är en typ av Shape.

Ett annat alternativ som vi kan använda vid downcast är nyckelordet is. Då slipper vi raden där vi försöker att konvertera i som en Shape. Istället kan vi göra allting i vår if sats.

object i = new Invoice(new Customer());

if (i is Shape)
{
	Console.WriteLine("Det gick");
}
else
{
	Console.WriteLine("Det gick inte");
}

Låt oss nu se på ett mer verkligt exempel där vi kan behöva använda downcast.

Här är den fullständiga koden för exemplet.
Detta är ett väldigt vanligt scenario i lite äldre C# applikationer. Vi skapar en lista av typen ArrayList(kom ihåg vad vi sade om detta i C# modulen).

namespace downcasting;

public class Customer
{
  public int Id { get; set; }
  public string Name { get; set; } = "";
}

namespace downcasting;

public class Invoice
{
  // Privata fält...
  private readonly Customer _customer;

  // Publika egenskaper...
  public string InvoiceNumber { get; set; } = "";
  public DateTime InvoiceDate { get; set; }
  public Double Total { get; set; }

  // Skapa en constructor metod som tar in en kund referens...
  public Invoice(Customer customer)
  {
    _customer = customer;
  }

  // Publika metoder...
  public void RenderInvoice()
  {
    var freight = CalculateFreight();
  }

  // Privata metoder för intern hantering i klassen...
  private int CalculateFreight()
  {
    return 0;
  }
}

namespace downcasting;

public class ReminderInvoice : Invoice
{
  private readonly int _fee;

  public ReminderInvoice(Customer customer) : base(customer)
  {

  }
}

namespace downcasting;

public class Vehicle
{
  public string RegistrationNumber { get; set; } = "";

  public Vehicle(string registrationNumber) { }
}

using System.Collections;
namespace downcasting;
internal class Program
{
  private static void Main(string[] args)
  {
    // Deklarera en lista som tar allt möjligt...
    var listOfStuff = new ArrayList();
    // Deklarera en lista som enbart tar typen Invoice och dess barn...
    var invoices = new List<Invoice>();

    listOfStuff.Add(new Invoice(new Customer()));
    listOfStuff.Add(new Invoice(new Customer()));
    listOfStuff.Add(new Invoice(new Customer()));
    listOfStuff.Add(new ReminderInvoice(new Customer()));
    // Hoppsan här kom något konstigt in i listan.
    listOfStuff.Add(new Vehicle("ABC123"));
    listOfStuff.Add(new ReminderInvoice(new Customer()));

    foreach (var item in listOfStuff)
    {
      invoices.Add((Invoice)item);
    }
  }
}

Det intressanta händer i Program klassen, här skapar vi en lista av typen ArrayList och sedan fyller vi på den med Invoice instanser och ReminderInvoice instanser. Men av misstag råkar vi även lägga till en instans av typen Vehicle.

I vår foreach loop så vill vi nu flytta varje objekt i vår listOfStuff lista till en lista som bara tillåter typen Invoice och dess härledda klasser, invoices.

Om vi nu kör applikationen så kommer den att krascha med följande felmeddelande:
Unhandled exception. System.InvalidCastException: Unable to cast object of type 'downcasting.Vehicle' to type 'downcasting.Invoice'.

Den faller på att vi av misstag har fått in en referens till en Vehicle klass.

Detta scenario är något som vi förr eller senare kommer att stöta på som utvecklare. Hur löser vi det då?

Vi får skriva om koden i vår foreach loop till följande istället.

foreach (var item in listOfStuff)
{
  // Kontrollera om objektet som vi itererar är av typen Invoice...
  if (item is Invoice)
  {
    // I så fall kan vi addera den till vår Invoice lista...
    invoices.Add((Invoice)item);
  }
}

Boxing och Unboxing

I den här sektionen ska vi gå igenom hur värde samt referens typer hanteras. Vi har redan gått igenom dessa två familjer av typer när vi diskuterade språket C# och hur olika datatyper hanteras i minnet. Vi sade bland annat att värde typer lagras direkt på stacken med variabelnamn och dess värde. Medans en referens typ, där lagras variabelns namn på stacken och dess värde placeras på minnesdelen heap. Där en adress skapas och returneras till variabeln på stacken. När vi sedan ville få åtkomst till en referens variabels värde så behövde C# först hämta variabelns namn ifrån stacken ta adressen och sedan söka upp värdet på minnesdelen heap.

Boxing/Unboxing

Vi har också lärt oss att klassen Object är basklassen för alla typer i .NET som om vi skulle gå tillbaka till vårt föregående exempel med fakturor och tittar i vår Program klass.

namespace boxing;

internal class Program
{
  private static void Main(string[] args)
  {
    ReminderInvoice reminder = new ReminderInvoice(new Customer());
    Invoice invoice = reminder;
    // Vi kan även göra så här...
    // När vi använder object som mottagande typ så gör vi en upcast...
    object demoInvoice = reminder;
  }
}

Boxing

Vad är boxing? Det är när C# och .NET konverterar en värde typ till en referens typ.

Ta följande exempel:

int y = 30;
object z = y;

// Vi kan även göra så här...
object x = 100;

Det som sker här är att vi konverterar en int till typen object.

Unboxing

Unboxing är motsatsen till boxing. Det vill säga att vi behöver konvertera vår referens typ(som egentligen är en värde typ) till en värde typ.

Exempel:

object x = 100; // här sker en konvertering till en referens typ...
int y = (int)x; // här konverterar vi tillbaka till en värde typ...
OBSERVERA att boxing och unboxing inte är gratis att utföra. Det finns en prestanda kostnad att betala för dessa momenten.

Klassiskt exempel på boxing

När inträffar boxing? Ett enkelt exempel på detta är att återigen använda en lista, som jag har sagt nu vid minst två tillfällen att vi inte ska använda😁.

var badList = new ArrayList();

// sträng värden är referenstyper så ingen boxing inträffar här
badList.Add("Mercedes");

// heltal är värdetyper så måste boxing göras för 
// att konvertera heltalet 300 till en referenstyp
badList.Add(300);

// DateTime är en struct och det är en värdetyp så 
// boxing måste ske här också.
badList.Add(DateTime.Today);

// Klasser är referenstyper så ingen boxing behövs här
badList.Add(new Customer());

Så vill vi nu plocka ut värdena för våra värdetyper såsom 300 och DateTime.Today ifrån vår ArrayList så måste vi använda unboxing.

var myNumber = (int)badList[1];
var myDate = (DateTime)badList[2];

Om vi utav misstag skulle välja fel värde ur listan som inte är av den typ som vi försöker konvertera till. Så kraschar applikationen.

Best Practices
Använd aldrig generella listor som ArrayList, istället försök att använda generiska listor som t ex List<int>, List<string>, List<DateTime> eller List<Customer> osv.

Åsidosätta metoder

Åsidosättning eller overriding är konceptet att förändra implementeringen i en metod som ärvs ifrån en basklass till en barnklass.

OBS!
Blanda inte ihop detta med överlagring av metoder(overloading) som vi redan har gått igenom. Överlagring betyder att vi kan använda samma metodnamn med olika argument till flera metoder.

För att kunna åsidosätta en metod ifrån en basklass, så måste basklassen tillåta detta. Dessutom måste barnklassen indikera att den kommer att åsidosätta metoden. Detta sker genom att vi använder två nya nyckelord

  • virtual i basklassen för att säga att det är ok att göra en egen implementering i barnklasserna.
  • override i barnklassen för att tala om att jag gör en egen implementering och struntar i basklassens implementering.

Låt oss se på ett enkelt exempel:

I detta exempel går vi tillbaka till klasserna Shape, Rectangle samt Circle. Vi lägger även till en ny form triangel, så vi skapar en Triangle klass.

Vi börjar med att skapa vår Shape klass, den är medvetet utan någon logik för fokus är att förstå åsidosättning av metoder.

Klassen Shape

namespace overriding;

public class Shape
{
  public Shape() { }
  
  // Observera nyckelordet virtual i definitionen av metoden
  // CalculateArea...
  public virtual void CalculateArea()
  {
    Console.WriteLine("Beräknar en area för en standard form");
  }
}

Vi lägger till nyckelordet virtual i definitionen av CalculateArea metoden. Detta betyder att metoden är tillåten att åsidosätta. Det vill säga skriva en egen implementation(logik) för dess beteende.

Klassen Rectangle

namespace overriding;

public class Rectangle : Shape
{

}

Klassen Rectangle ärver ifrån klassen Shape, men vi åsidosätter inte CalculateArea i Rectangle. Så vi kommer nu att använda samma implementering som Shape.

Klassen Circle

namespace overriding;

public class Circle : Shape
{
  // Observera nyckelordet override...
  public override void CalculateArea()
  {
    Console.WriteLine("Beräknar arean på en cirkeln");
  }
}

I klassen Circle så använder vi nyckelordet override för att kunna åsidosätta CalculateArea metoden. Det vill säga att Circle klassen behöver en egen implementering(logik) för att beräkna arean.

Klassen Triangle

namespace overriding;

public class Triangle : Shape
{
  // Observera nyckelordet override...
  public override void CalculateArea()
  {
    Console.WriteLine("Beräknar arean för en triangel");
  }
}

I klassen Triangle använder vi också nyckelordet override för att åsidosätta implementeringen av metoden CalculateArea.

Klassen Program

internal class Program
{
  private static void Main(string[] args)
  {
    var rectangle = new Rectangle();
    var circle = new Circle();
    var triangle = new Triangle();

    rectangle.CalculateArea();
    circle.CalculateArea();
    triangle.CalculateArea();

  }
}

Här skapar vi tre referens variabler.

  • rectangle som är av typen Rectangle
  • circle som är av typen Circle
  • triangle som är av typen Triangle

Sedan anropar vi CalculateArea på respektive referens

Resultat är som följer:

Beräknar en area för en standard form
Beräknar arean på en cirkeln
Beräknar arean för en triangel

Vad vi kan se här är följande:

  • Anropet CalculateArea på referensen rectangle exekveras av Shape klassens implementation.
  • CalculateArea anropet på referensen circle exekveras av Circle klassens implementation.
  • CalculateArea anropet på referensen triangle exekveras av Triangle klassens implementation.

Detta är ett enkelt exempel på åsidosättning(overriding).

Polymorfism/Polymorphism

Polymorfism kommer ifrån grekiskan och betyder många former(Poly = många, morfism = former).

Vad betyder det för oss som utvecklare?

Det betyder enkelt uttryckt att vi kan skriva kod på ett mycket enkelt sätt och inte behöver hantera vilken metod i vilket sammanhang ska användas.

Ett mer teknisk beskrivning är att basklassen kan anropa åsidosatta metoder i  barnklasser  genom att basklassen vet om den refererar ett barn eller sig självt.

För att se detta beteende låt oss gå tillbaka till vårt föregående exempel och endast nu ändra i klassen Program.

namespace polymorphism;

internal class Program
{
  private static void Main(string[] args)
  {
    var shapes = new List<Shape>();

    shapes.Add(new Rectangle());
    shapes.Add(new Shape());
    shapes.Add(new Circle());
    shapes.Add(new Rectangle());
    shapes.Add(new Triangle());
    shapes.Add(new Circle());
    shapes.Add(new Triangle());

    foreach (var shape in shapes)
    {
      shape.CalculateArea();
    }
  }
}

Förändringen som är gjord är att vi skapar en generisk lista som kan innehålla klassen Shape, List<Shape>. Till denna lista lägger vi till ett antal olika former som cirklar, rektanglar, trianglar osv...

Vi skapar sedan en foreach loop som för varje objekt som finns i vår shapes lista anropar CalculateArea metoden.

Resultatet av ovan kod blir följande:

Beräknar en area för en standard form
Beräknar en area för en standard form
Beräknar arean på en cirkeln
Beräknar en area för en standard form
Beräknar arean för en triangel
Beräknar arean på en cirkeln
Beräknar arean för en triangel

Tack vare att våra klasser Circle, Triangle och Rectangle alla ärver ifrån Shape klassen. Så behöver vi inte kontrollera vad för typ respektive objekt är i listan och sedan anropa korrekt implementering av CalculateArea metoden. Detta görs nu polymorfistiskt via basklassen. Vi kan på detta sätt skapa en väldigt enkel kod men vi har även skapat ett minimalt beroende mellan vår basklass och barnklasserna, loose coupling.

För att visa oberoendet låt oss lägga till en ny klass i vårt exempel som ärver ifrån klassen Shape. Låt oss skapa en ny klass Octagon.

namespace polymorphism;

public class Octagon : Shape
{
  public override void CalculateArea()
  {
    Console.WriteLine("Beräknar arean av en oktagon");
  }
}

Gör sedan följande ändring i Program klassen.

// Lägg till en referens till Octagon klassen i listan.
shapes.Add(new Octagon());

Kör nu applikationen igen och resultatet ser ut som följer:

Beräknar en area för en standard form
Beräknar en area för en standard form
Beräknar arean på en cirkeln
Beräknar en area för en standard form
Beräknar arean för en triangel
Beräknar arean på en cirkeln
Beräknar arean för en triangel
Beräknar arean av en oktagon

Vi ser att CalculateArea metoden även anropas för vår nya klass Octagon. Vi har inte gjort någon ändring någon annanstans i vår applikation. Vi har inte ändrat eller lagt till något i basklassen Shape. Shape har ingen aning om att det finns en ny klass och bryr sig inte heller.

Vi har nu lyckats få ihop en loosely coupled arvs hierarki.

Abstrakta klasser

Abstrakta klasser eller Abstract classes representerar ett koncept eller idé. Till exempel "Vad är former?", "Vad är fordon?" osv... Observera frågan "Vad är...?"

Abstrakta klasser kan endast användas vid arv, abstrakta klasser tillåter inte att de instansieras. Det vill säga att vi inte kan skapa ett objekt ifrån en abstrakt klass.

En abstrakt klass definierar och skapar gränssnittet som ska kunna kommuniceras med. Kom ihåg den svarta lådan! Barnklasserna eller de härledda klasserna tillhandahåller implementeringen av gränsnittet.

En abstrakt klass kan ha abstrakta metoder, vilket tvingar barnklasser att skapa implementering av dessa metoder.

Vad är ett gränssnitt?

Ett gränssnitt i objekt orienterad programmering är de metoder och egenskaper som vi vill presentera utåt eller enbart för barnklasser.

Hur skapar vi en abstrakt klass?

En abstrakt klass skapas genom att använda åtkomst skyddet abstract på en klass.

Så säg att vi vill göra vår Shape klass i föregående exempel till en abstrakt klass. Vad vi behöver göra är att lägga till nyckelordet abstract framför class nyckelordet.

namespace abstract_classes;

public abstract class Shape
{
  public Shape() { }
  public virtual void CalculateArea()
  {
    Console.WriteLine("Beräknar en area för en standard form");
  }
}

Vad vi nu har gjort är att säga att klassen Shape inte går att skapa ett objekt ifrån. Nu kommer vi att få ett fel i vår Program klass, på den rad där vi lägger till en ny instans av Shape klassen i listan.

Abstract classes

Vi får en tydlig indikation på att vi inte kan använda en abstrakt klass via en instansering. Om vi tar bort den raden och kör om applikationen, så fungerar den som tidigare.

Vi har ett problem i vår Shape klass och det är att vi har implementerat metoden CalculateArea. Problemet är att Shape är för abstrakt för att kunna implementera logik för att beräkna arean. Vi vet inte vilken typ av form(shape) som ska beräknas, så vi kan säga att klassen Shape bara representerar ett koncept eller idé om former. Det borde vara våra barnklassers uppgift att beräkna sin egen area. De är de enda som vet vilken form de har och kan på så sätt använda rätt algoritm för beräkningen. Hur tvingar vi våra barnklasser att hantera implementeringen då? Genom att göra basklassens CalculateArea abstrakt.

Abstrakta metoder

Så låt oss göra om metoden CalculateArea i vår Shape klass till att tvinga implementeringen ner till barnklasserna. Öppna upp Shape klassen och gör följande ändringar.

namespace abstract_classes;

public abstract class Shape
{
  public Shape() { }
  public abstract void CalculateArea();
}

Observera att en abstrakt metod inte har någon "body", utan enbart definitionen.

Nu får vi ett problem i vår Rectangle klass. Tidigare i den klassen så använde vi Shape klassens implementering av CalculateArea metoden. När vi nu har definierat CalculateArea metoden i Shape klassen som abstract. Så säger vi att alla barnklasser MÅSTE implementerar den. Abstract nyckelordet på metoder är tvingande.

Okej då, då får vi väl implementera metoden i vår Rectangle klass. Så lägg till följande kod i klassen Rectangle.

namespace abstract_classes;

public class Rectangle : Shape
{
  public override void CalculateArea()
  {
    Console.WriteLine("Beräknar arean på en rektangel");
  }
}

Om vi nu kör om applikationen så kommer den att fungera som tidigare, förutom att vi inte direkt kan använda Shape klassen som referens.

Beräknar arean på en rektangel
Beräknar arean på en cirkeln
Beräknar arean på en rektangel
Beräknar arean för en triangel
Beräknar arean på en cirkeln
Beräknar arean för en triangel
Beräknar arean av en oktagon

Abstrakta egenskaper

Vi kan givetvis även skapa abstrakta egenskaper i våra klasser.
Exempel:

namespace abstract_classes;

public abstract class Shape
{
  public abstract int Height { get; set; }
  public abstract int Width { get; set; }
  
  public Shape() { }
  
  public abstract void CalculateArea();
}

Här skapar vi två abstrakta egenskaper/fält Height och Width, vilket givetvis tvingar våra barnklasser att implementera dem.

Varför ska vi använda abstrakta klasser och metoder?

Om vi behöver att tillhandahålla ett beteende som är gemensamt, oftast talar vi om ett gemensamt vokabulär i dessa sammanhang. Samt att vi vill tvinga utvecklare som använder vår design att följa detta vokabulär.

Regler för abstrakta klasser och metoder

  • Abstrakta klasser går inte att instansiera
  • Abstrakta metoder eller egenskaper FÅR inte inkludera någon implementering
  • Om metoder eller egenskaper är definierade som abstrakta, så måste alla barnklasser implementera dem.
  • Om metoder eller egenskaper är definierade som abstrakta då MÅSTE också klassen definieras som abstrakt också

Låsta eller Sealed klasser

Låsta klasser(sealed classes) är motsatsen till abstrakta klasser. En klass som är definierad som sealed kan inte ärvas.

Detta är för att förhindra att någon ärver klasser och förändrar implementering av metoder som kan i sin tur generera eller skapa buggar i system. Det kan t ex vara så att vi har skapat en klass med all implementering som behövs för att manipulera tillståndet och vi vill inte att tillståndet ska manipuleras på annat sätt än det vi har definierat.

I .NET finns det ett antal klasser som är låsta som t ex klassen string.

Det ska sägas att det är väldigt ovanligt att vi använder klasser som är låsta.

Enligt min mening så är sealed ett "anti-pattern", vad jag menar med detta är att det går tvärs emot tanken på arv och hierarkier av klasser och att kunna bygga på ny funktionalitet vid behov samt att kunna skapa klasser med mjuka kopplingar(loosely coupled).

Så försök att inte använda sealed i era applikationer, det är en "Bad Practise". Jag ville ha med det så ni får en fullständig bild av objekt orienteringens principer och göra er medvetna om att vissa klasser i .NET är låsta(sealed).

Men innan vi avslutar denna modul, låt oss bara se hur vi kan använda nyckelordet sealed i vårt föregående exempel. Låt oss öppna upp vår Octagon klass och göra följande ändring.

namespace abstract_classes;

public sealed class Octagon : Shape
{
  public sealed override void CalculateArea()
  {
    Console.WriteLine("Beräknar arean av en oktagon");
  }
}

Observera nyckelordet sealed på både klassen och på metoden CalculateArea. Om vi nu försöker skapa en ny klass newShape och försöker ärva ifrån Octagon klassen så får vi följande meddelande:

Avslutning

I den här modulen har vi gått igenom allt om arv och arvshierarkier och hur vi kan utnyttja polymorfism för att skapa mjuka kopplingar mellan klasser. I nästa modul ska vi gå igenom Interface programmering och vinsten, syftet med ytterligare separering mellan implementering och gränssnitt.