Principper og mønstre
Med udgangspunkt i den arkitektur, vi har set på i forrige kapitel (fx monolit eller lagdelt arkitektur), kan vi gøre koden mere robust og fleksibel ved at følge en række grundlæggende principper og mønstre. Der findes flere kendte principper for objektorienteret programmering, og de mest udbredte og anerkendte er SOLID-principperne, formuleret af Robert C. Martin (også kaldet “Uncle Bob”), samt en lang række Design Patterns, særligt dem, der blev populariseret af “Gang of Four” i bogen Design Patterns: Elements of Reusable Object-Oriented Software (1994).
Info
Du kan finde eksempler på brug af flere mønstre på devcronberg/csharpeksempler
Objektorienterede principper
Før vi går i dybden med SOLID og designmønstre, er det værd at genopfriske de fire grundprincipper, der kendetegner objektorienteret programmering:
-
Encapsulation (indkapsling)
Indkapsling handler om at skjule data og unødvendig kompleksitet for omverdenen. Man bestemmer, hvilke dele af klassen der skal være offentligt tilgængelige (public) og hvilke, der skal holdes skjult (private). På den måde beskytter man klassens indre tilstand og undgår, at anden kode utilsigtet ændrer på data. -
Abstraction (abstraktion)
Abstraktion går hånd i hånd med indkapsling og betyder, at vi kun fremhæver de vigtigste dele af et objekt og undlader de detaljer, som brugeren af klassen ikke har behov for at vide. -
Inheritance (arv)
Arv gør det muligt at oprette nye klasser baseret på eksisterende, hvor de “arver” felter, egenskaber og metoder. Man kan således genbruge kode og specialisere den i underklasser. -
Polymorphism (polymorfi)
Polymorfi (mange former) handler om, at et metodekald kan afvikles på forskellige måder alt efter, hvilken konkret klasse vi arbejder med. I praksis betyder det fx, at en metode kan have forskellige implementeringer, uafhængigt af hvilket objekt metoden kaldes på.
Mutabilitet i OOP
Selvom mutabilitet (om et objekts tilstand kan ændres eller ej) ikke er en “klassisk” søjle i OOP, spiller det en stor rolle for, hvor let det er at teste, fejlsøge og vedligeholde kode:
- Mutable objekter kan ændre deres indre tilstand efter oprettelse. Det er nyttigt i mange scenarier (f.eks. en terning, der skal vise nye værdier ved hvert kast).
- Immutable objekter (uforanderlige) ændrer ikke tilstand, når de først er instantieret. De er nemmere at debugge og trådsikre men kan være mindre fleksible, hvis dit domæne faktisk kræver, at data kontinuerligt ændres.
Funktionsorienterede sprog, såsom F#, Haskell, Erlang og Elixir, lægger stor vægt på immutabilitet. I disse sprog er data som standard uforanderlige, hvilket betyder, at når en værdi er tildelt en variabel, kan den ikke ændres. Dette designvalg har flere fordele:
- Forudsigelighed: Uforanderlige data gør det lettere at forstå og forudsige, hvordan programmet vil opføre sig, da værdier ikke ændres uventet.
- Trådsikkerhed: Immutabilitet gør det nemmere at skrive trådsikre programmer, da der ikke er nogen risiko for, at flere tråde ændrer den samme data samtidig.
- Enklere fejlsøgning: Når data ikke kan ændres, er det lettere at spore, hvor og hvornår en værdi blev tildelt, hvilket gør debugging mere ligetil.
Disse egenskaber gør funktionsorienterede sprog særligt velegnede til parallelle og distribuerede systemer, hvor konsistens og forudsigelighed er afgørende.
SOLID-principperne – et historisk blik
Robert C. Martin introducerede SOLID-principperne i starten af 2000’erne for at hjælpe udviklere med at skrive mere vedligeholdelig og fleksibel objektorienteret kode. Selve forkortelsen SOLID henviser til fem principper:
Single Responsibility Principle (SRP)
Hver klasse eller komponent bør have ét klart ansvarsområde – eller sagt på en anden måde: kun én grund til at ændre sig. Hvis din klasse har flere ansvarsområder, risikerer du, at ændringer i ét område bryder funktionaliteten i et andet.
Her er et simpelt eksempel:
// Faktura har kun ansvar for data
public class Faktura
{
public int Id { get; set; }
public decimal Beløb { get; set; }
public void GemIDatabase()
{
Console.WriteLine("Faktura gemt i databasen");
}
public void Send(Faktura faktura)
{
Console.WriteLine("Faktura sendt via e-mail");
}
}
I dette eksempel har Faktura-klassen både ansvar for at gemme fakturaer i databasen og sende dem via e-mail. Hvis vi ændrer, hvordan vi gemmer fakturaer, kan det påvirke e-mail-funktionaliteten og omvendt. Dette bryder Single Responsibility Principle (SRP), fordi klassen har flere grunde til at ændre sig.
Her er en forbedret version, der overholder SRP:
// Faktura har kun ansvar for data
public class Faktura
{
public int Id { get; set; }
public decimal Beløb { get; set; }
}
// Ansvar for at gemme fakturaer
public class FakturaDataService
{
public void GemIDatabase(Faktura faktura)
{
Console.WriteLine("Faktura gemt i databasen");
}
}
// Ansvar for at sende fakturaer via e-mail
public class FakturaEmailService
{
public void Send(Faktura faktura)
{
Console.WriteLine("Faktura sendt via e-mail");
}
}
Single Responsibility Principle (SRP) siger, at en klasse kun skal have et ansvarsområde.
- Faktura håndterer kun fakturadata – den ved intet om lagring eller e-mail.
- FakturaDataService håndterer kun lagring – hvis databasen skiftes, påvirker det ikke andre klasser.
- FakturaEmailService håndterer kun e-mail – hvis vi f.eks. skifter SMTP metode, ændrer vi kun denne klasse.
Denne opdeling gør systemet mere fleksibelt, nemmere at vedligeholde og mindre fejlbehæftet, fordi hver klasse kun har ét ansvar.
Open/Closed Principle (OCP)
Koden skal være åben for udvidelse, men lukket for ændring. Du bør kunne tilføje nye funktioner eller variationer uden at ændre den eksisterende, gennemtestede kode for meget. Ofte løses dette ved hjælp af arv, interfaces eller abstraktioner.
Forestil dig en simpel løn-beregner, hvor vi har en metode, der håndterer både fuldtids- og deltidsansatte:
public class LønBeregner
{
public decimal BeregnLøn(Medarbejder medarbejder)
{
if (medarbejder.Type == "Fuldtid")
{
return medarbejder.Månedsløn;
}
else if (medarbejder.Type == "Deltid")
{
return medarbejder.Timesats * medarbejder.Arbejdstimer;
}
return 0;
}
}
public class Medarbejder
{
public string Type { get; set; }
public decimal Månedsløn { get; set; }
public decimal Timesats { get; set; }
public int Arbejdstimer { get; set; }
}
LønBeregner
. Det bryder OCP, fordi assen ikke er lukket for ændringer. I stedet for at ændre eksisterende kode, gør vi systemet åbent for udvidelse ved at bruge arv og polymorfi:
// Abstrakt klasse for medarbejdere
public abstract class Medarbejder
{
public string Navn { get; set; }
public abstract decimal BeregnLøn();
}
// Fuldtidsmedarbejder
public class FuldtidsMedarbejder : Medarbejder
{
public decimal Månedsløn { get; set; }
public override decimal BeregnLøn()
{
return Månedsløn;
}
}
// Deltidsmedarbejder
public class DeltidsMedarbejder : Medarbejder
{
public decimal Timesats { get; set; }
public int Arbejdstimer { get; set; }
public override decimal BeregnLøn()
{
return Timesats * Arbejdstimer;
}
}
// Ny medarbejdertype: Freelancer
public class Freelancer : Medarbejder
{
public decimal Honorarsats { get; set; }
public int Timer { get; set; }
public override decimal BeregnLøn()
{
return Honorarsats * Timer;
}
}
// LønBeregner kender kun til Medarbejder og behøver ikke ændres
public class LønBeregner
{
public decimal BeregnLøn(Medarbejder medarbejder)
{
return medarbejder.BeregnLøn();
}
}
Open/Closed Principle (OCP) siger, at en klasse skal være åben for udvidelse, men lukket for ændring.
- I den første version skulle vi ændre
LønBeregner
, hver gang vi tilføjede en ny medarbejdertype. - I den forbedrede version kan vi tilføje nye medarbejderklasser uden at røre
LønBeregner
.
Dette gør koden mere fleksibel, lettere at vedligeholde og mindre fejlbehæftet, fordi eksisterende kode forbliver uberørt.
Liskov Substitution Principle (LSP)
Subklasser skal kunne erstatte deres superklasse uden at bryde den overordnede logik.
Her er et eksempel på Liskov Substitution Principle (LSP). Forestil dig en klasse for firkanter, hvor vi også laver en underklasse for kvadrater:
public class Firkant
{
public virtual int Bredde { get; set; }
public virtual int Højde { get; set; }
public int BeregnAreal()
{
return Bredde * Højde;
}
}
public class Kvadrat : Firkant
{
public override int Bredde
{
set { base.Bredde = base.Højde = value; }
}
public override int Højde
{
set { base.Højde = base.Bredde = value; }
}
}
Hvis man har en metode, der arbejder med en firkant, men får en kvadrat, kan den opføre sig uventet:
public void TestFirkant(Firkant firkant)
{
firkant.Bredde = 5;
firkant.Højde = 10;
Console.WriteLine(firkant.BeregnAreal()); // Forventet: 50, men Kvadrat returnerer 100
}
I stedet for at lade kvadrat arve fra firkant, kan vi bruge en mere passende abstraktion:
public abstract class Figur
{
public abstract int BeregnAreal();
}
public class Firkant : Figur
{
public int Bredde { get; set; }
public int Højde { get; set; }
public override int BeregnAreal()
{
return Bredde * Højde;
}
}
public class Kvadrat : Figur
{
public int Side { get; set; }
public override int BeregnAreal()
{
return Side * Side;
}
}
Liskov Substitution Principle siger, at en underklasse skal kunne bruges i stedet for sin baseklasse uden at ændre korrektheden af programmet.
I den første version kunne man ikke frit erstatte en firkant med en kvadrat uden at skabe fejl. I den anden version er begge klasser deres egne figurer, hvilket sikrer, at programmet fungerer korrekt, uanset om man bruger en firkant eller et kvadrat.
Denne løsning gør koden mere robust og lettere at forstå, da hver klasse opfører sig forudsigeligt og uden skjulte afhængigheder.
Interface Segregation Principle (ISP)
Mange små, specifikke interfaces er bedre end ét stort, altomfavnende interface. Udviklere bør ikke være tvunget til at implementere metoder, de ikke har brug for.
Her er et eksempel på Interface Segregation Principle (ISP). Forestil dig et interface, der håndterer printerfunktioner:
public interface IPrinter
{
void Print(string indhold);
void Scan(string indhold);
void Fax(string indhold);
}
public class EnkelPrinter : IPrinter
{
public void Print(string indhold)
{
Console.WriteLine("Udskriver: " + indhold);
}
public void Scan(string indhold)
{
throw new NotImplementedException();
}
public void Fax(string indhold)
{
throw new NotImplementedException();
}
}
Scan
og Fax
, hvilket bryder ISP. I stedet for et stort interface opdeler vi det i mindre, mere specifikke interfaces:
public interface IPrint
{
void Print(string indhold);
}
public interface IScan
{
void Scan(string indhold);
}
public interface IFax
{
void Fax(string indhold);
}
public class EnkelPrinter : IPrint
{
public void Print(string indhold)
{
Console.WriteLine("Udskriver: " + indhold);
}
}
public class MultifunktionsPrinter : IPrint, IScan, IFax
{
public void Print(string indhold)
{
Console.WriteLine("Udskriver: " + indhold);
}
public void Scan(string indhold)
{
Console.WriteLine("Scanner: " + indhold);
}
public void Fax(string indhold)
{
Console.WriteLine("Sender fax: " + indhold);
}
}
Interface Segregation Principle siger, at et interface ikke skal tvinge en klasse til at implementere metoder, den ikke har brug for.
I den første version blev EnkelPrinter
tvunget til at implementere scanning og fax, selvom den ikke brugte dem. I den anden version kan hver printer kun implementere de funktioner, den har brug for, hvilket gør koden mere fleksibel og lettere at vedligeholde.
Dependency Inversion Principle (DIP)
Høj-niveaulag må ikke afhænge direkte af lav-niveaulag. Begge lag bør afhænge af abstraktioner, ikke konkrete implementeringer. Dette princip ses ofte i brug via “Dependency Injection”, hvor du gennem konstruktører eller konfigurationsfiler kan udskifte implementationer uden at ændre i koden, der bruger dem.
Her er et eksempel på Dependency Inversion Principle (DIP) anvendt til lagring af data i CSV og JSON.
I dette eksempel afhænger DataService direkte af CsvDataService:
public class CsvDataService
{
public void GemData(string data)
{
Console.WriteLine("Data gemt i CSV-fil: " + data);
}
}
public class DataService
{
private readonly CsvDataService _dataService;
public DataService()
{
_dataService = new CsvDataService(); // Direkte afhængighed
}
public void Gem(string data)
{
_dataService.GemData(data);
}
}
Her er en mulig løsning hvor der introduceres et interface og brug af Dependency Injection:
public interface IDataService
{
void GemData(string data);
}
public class CsvDataService : IDataService
{
public void GemData(string data)
{
Console.WriteLine("Data gemt i CSV-fil: " + data);
}
}
public class JsonDataService : IDataService
{
public void GemData(string data)
{
Console.WriteLine("Data gemt i JSON-fil: " + data);
}
}
public class DataService
{
private readonly IDataService _dataService;
public DataService(IDataService dataService) // Dependency Injection
{
_dataService = dataService;
}
public void Gem(string data)
{
_dataService.GemData(data);
}
}
Hvis vi vil skifte fra CSV til JSON, kan vi blot bruge JsonDataService i stedet for CsvDataService, uden at ændre DataService:
IDataService csvService = new CsvDataService();
DataService dataServiceCsv = new DataService(csvService);
dataServiceCsv.Gem("Testdata til CSV");
IDataService jsonService = new JsonDataService();
DataService dataServiceJson = new DataService(jsonService);
dataServiceJson.Gem("Testdata til JSON");
Dependency Inversion Principle siger, at høj-niveau-moduler (som DataService) ikke må være afhængige af lav-niveau-moduler (som CsvDataService eller JsonDataService). Begge skal i stedet afhænge af abstraktioner.
I den første version var DataService afhængig af en konkret klasse, hvilket gjorde den ufleksibel. I den forbedrede version afhænger den kun af et interface, hvilket gør det let at udskifte implementeringen uden at ændre selve DataService.
Denne løsning gør koden mere fleksibel, testbar og vedligeholdelsesvenlig.
Litteratur om SOLID
- Robert C. Martins bog Agile Software Development, Principles, Patterns, and Practices (2002) er et godt udgangspunkt.
- Clean Code (2008) af samme forfatter er også en populær bog, der fokuserer på læsbar og vedligeholdelsesvenlig kode, men som indirekte berører SOLID-principperne.
Design Patterns – historik og formål
Design Patterns er dokumenterede løsninger på gentagne problemer i softwaredesign. De blev for alvor kendte med “Gang of Four”–bogens (Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides) udgivelse i 1994. Formålet med design patterns er at genbruge gode løsninger i stedet for at opfinde den dybe tallerken hver gang.
Man inddeler ofte mønstrene i tre overordnede kategorier:
-
Creational (f.eks. Singleton, Factory Method, Builder)
- Fokus på hvordan man opretter objekter på en fleksibel måde.
- Kan gøre koden mere robust over for fremtidige ændringer i, hvordan objekter bliver instantieret.
-
Structural (f.eks. Adapter, Decorator, Facade)
- Håndterer sammensætningen af objekter og klasser til større strukturer.
- Koden bliver lettere at vedligeholde og ændre, fordi man har klare måder at koble moduler eller klasser sammen.
-
Behavioral (f.eks. Observer, Strategy, Command)
- Løser, hvordan objekter interagerer med hinanden, og hvordan ansvar fordeles.
- Hjælper ofte med at reducere afhængigheder mellem klasser og fremme udvidelsesmuligheder.
Praktiske fordele ved patterns
- Nemt at kommunikere: Når du siger “Vi bruger et Singleton-mønster her” til en anden udvikler, ved de præcis, hvilken arkitekturidé du har.
- Dokumenteret viden: Patterns kommer med gennemprøvede fordele og kendte faldgruber.
- Forbedret struktur: De hjælper med at undgå “spaghetti-kode”, fordi mønstrene typisk kræver en klar opsplitning af ansvar.
Videre læsning
- “Gang of Four”-bogen, Design Patterns: Elements of Reusable Object-Oriented Software (1994)
- Head First Design Patterns (2004) af Freeman & Freeman, som er en mere letlæst indføring med mange eksempler.