Hur skriver man SOLID kod?

Dela på
Hur skriver man SOLID kod?


Att programmera är svårt och ju mer man lär sig desto svårare blir det. Det är inte tekniken i sig som blir svårare, den lär man sig genom att läsa en bok eller gå en kurs, utan svårigheten ligger i hur man ska strukturera sin kod. När jag började programmera var jag nöjd med att koden kompilerade samt gjorde det jag förväntade mig men med erfarenhet kommer också insikten att det finns ett ytterligare krav: koden måste gå att förstå. Man inser att kod inte bara är till för datorer utan att bra kod måste kunna läsas, förstås och framförallt ändras av andra programmerare. Hur gör man då för att skriva bra kod?



Vad är dålig kod?

För att veta hur man skriver bra kod kan det vara bra att veta vad som är dålig kod och hur den uppkommer. Robert C. Martin har i sin artikel “Design Principles and Design Patterns” hittat fyra symptom på dålig kod:



  • Stelhet (Rigidity) – En ändring i koden kräver att man ändrar på flera andra ställen och dessa ändringar kan i sig ge upphov till ytterligare ändringar o.s.v. Detta gör att det är svårt att uppskatta hur lång tid en ändring kan ta så man drar sig för att ändra något överhuvudtaget.
  • Skörhet (Fragility) – En ändring i koden skapar buggar på andra ställen, kanske i tillsynes helt orelaterade delar. Gör koden omöjlig att underhålla, en buggfix skapar ytterligare en eller flera nya buggar, in absurdum.
  • Orörlighet (Immobility) – Svårighet att återanvända kod. Detta leder till kodduplicering som gör koden svårare att underhålla då ändringar måste göras på flera ställen.
  • Viskositet (Viscosity) – Finns i två former, viskositet i design och viskositet i miljö. När det är lättare att skapa dålig kod (fulhack) än det är att upprätthålla den existerande designen vid en ändring är viskositeten i designen hög. När det är svårt att utföra vissa steg i utvecklingsprocessen, t.ex. köra enhetstester, är viskositeten i miljön hög. Man riskerar då att dålig kod skrivs eller att tester skippas och fel uppstår.

Hur uppkommer dålig kod?

Vad är det då som gör att detta inträffar? Jag tror att de flesta projekt börjar med en bra design och bra kod. Om kraven bara inte hade ändrat sig hade nog allt gått bra men som vi alla vet (eller borde veta) ändrar sig mjukvarukrav hela tiden. Jag brukade använda ett byggprojekt som metafor för utveckling. Byggnadsingenjörer har förfinat sin konst i årtusenden och kan planera och utföra komplexa uppdrag med tusentals människor inblandade utan att skapa oreda eller förseningar, så varför kan då inte vi som bygger mjukvara göra samma sak? Sanningen ligger nog i hur kraven hanteras. Om en projektledare för en skyskrapa bestämde sig för att takhöjden på bottenplanet nog borde vara 30cm högre efter att hela stommen var på plats tror jag inte att någon skulle lyssna, han skulle nog få sparken i stället. Men när det gäller mjukvara kan kraven ändras till synes när som helst.



Man får nog använda sig av andra metaforer för att förstå mjukvara. I boken “The Pragmatic Programmer” beskriver författarna mjukvara som trädgårdsskötsel: Man planerar sin trädgård under ordnade former men sedan växer vissa växter bra medan andra dör, vissa måste flyttas till ljusare ställen och andra blir för stora och måste delas upp. Man måste hela tiden rensa ogräs och hålla efter växterna så att inte allt blir en enda djungel.



Hur ska man motverka dålig kod?

Vad ska man då göra för att hantera en föränderlig kravbild och kod som växer okontrollerat? Ett sätt är att använda sig av objektorienterad design. Grundtanken är alltså att abstrahera det man ska utveckla med hjälp av objekt. Dessa objekt ska både innehålla information och funktionalitet (annars är det en global funktion eller en datastruktur). Detta ger naturligt en uppdelning av kod och separerar beroenden. Ytterligare en fördel är att objekt och deras interaktion känns naturligt att tänka på vilket underlättar modellering av problemet.



Objektorienterad design ökar dock komplexiteten på koden och gör det därför möjligt att skriva ännu sämre kod. Detta motarbetas genom att man använder sig av designprinciper och designmönster för att det ska bli bra. Robert C. Martin listar ett antal sådana principer och Michael Feathers myntade akronymen SOLID för de fem första.



SOLID

SOLID står för:



  • Single Responsibility Principle
  • Open Closed Principle
  • Liskov Substitution Principle
  • Interface Segregation Principle
  • Dependency Inversion Principle

Single Responsibility Principle

Denna princip är enkel att förstå men svår att upprätthålla. Den säger att en klass ska bara ha en enda orsak att ändras. Detta är samma sak som att säga att klassen ska ha hög sammanhållning (cohesion). Ett exempel:






1
2
3
4
5
6
class Employee
{
public Money CalculatePay();
public void Save();
public String ReportHours()
}
Denna klass har tre olika skäl att ändras:



  1. Affärsreglerna hur man beräknar lön ändras
  2. Sättet att spara arbetstagare ändras (disk till databas t.ex.)
  3. Formatet på hur timmar redovisas
Lösningen här är enkel, dela upp funktionaliteten i olika klasser som inte beror på varandra.



MVC designmönstret följer denna princip genom att bryta loss den grafiska presentationen från den kod som skapar data som ska presenteras.



Open Closed Principle

Detta är en av de viktigaste principerna. Klasser skall gå att utöka utan att de behöver ändras. Låter det svårt? Lösningen ligger i abstraktioner (arv eller interface). Här är ett exempel jag lånar från Joel Abrahamsson. Vi har en rektangelklass:






1
2
3
4
5
public class Rectangle
{
public double Width { get; set; }
public double Height { get; set; }
}



Låt oss anta att vi har ett gäng sådana rektanglar i en lista och nu vill vi räkna ut arean på dessa. Så vi skapar en klass för detta:






1
2
3
4
5
6
7
8
9
10
11
12
public class AreaCalculator
{
public double Area(Rectangle[] shapes)
{
double area = 0;
foreach (var shape in shapes)
{
area += shape.Width*shape.Height;
}
return area;
}
}



Allt fungerar bra, men då ändras kraven. Helt plötsligt ska även cirklar kunna ligga med i listan och AreaCalculator ska fungera för båda två. Vi ändrar då metoden Area till:






1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public double Area(object[] shapes)
{
double area = 0;
foreach (var shape in shapes)
{
if (shape is Rectangle)
{
Rectangle rectangle = (Rectangle) shape;
area += rectangle.Width*rectangle.Height;
}
else
{
Circle circle = (Circle)shape;
area += circle.Radius * circle.Radius * Math.PI;
}
}

return area;
}



Här ser man genast problemet med en klass som inte tillåter utökning utan modifiering. Om vi i framtiden har en uppsjö olika former kommer Area bli en enda röra av if satser. Då är det bättre att använda arv och skapa en abstrakt basklass Shapes som sedan alla former får ärva av:






1
2
3
4
public abstract class Shape
{
public abstract double Area();
}
Då kan vi skriva om Area enligt:




1
2
3
4
5
6
7
8
9
public double Area(Shape[] shapes)
{
var area = 0;
foreach (var shape in shapes)
{
area += shape.Area();
}
return area;
}
Denna metod är stängd för modifiering och kan användas på alla former som ärver av Shape.



Liskov Substitution Principle

Principen är att subklasser alltid skall vara utbytbara mot sina basklasser. Låter enkelt eller hur? Tyvärr kan klasser som bryter mot detta vara svåra att upptäcka. Ett exempel är cirkel/ellips dilemmat.



Följande gäller: En cirkel är en ellips där brännpunkterna sammanfaller, alltså är det naturligt att låta Circle ärva av Ellipse. Problemet är bara att en cirkel inte har två brännpunkter. En enkel lösning vore att i metoden som sätter brännpunkt A också sätta brännpunkt B till samma värde och vice versa. Problemet uppstår när vi ska testa ellipsklassen:






1
2
3
4
5
6
7
8
void TestEllips()
{
var Ellipse e = new Circle();
e.FocusA = 5;
e.FocusB = 9;
assert(e.FocusA == 5);
assert(e.FocusB == 9);
}



En användare av Ellipse kan förvänta sig att alla subklasser till Ellipse beter sig exakt lika som basklassen. I ovanstående kod är det inte så, rad 5 ger ett fel, och således kan inte Circle ärva av Ellipse.



Om man bryter mot denna princip medför det att man ibland måste testa viken subklass man jobbar mot, Circle eller Ellipse t.ex, något som i sin tur bryter mot Open Cosed principen.



Interface Segregation Principle

Kanske inte den mest användbara principen men den säger att en klient skall inte tvingas bero på metoder som den inte använder. Man ska alltså inte skapa ett stort interface för en serviceklass som innehåller flera klientklassers beteende. I stället ska man skapa flera interface, ett för varje klientklass, och sedan låta serviceklassen ärva alla dessa. På så sätt blir varje klientklass opåverkad av ändringar i andra klientklasser.



Dependency Inversion Principle

Denna princip är det huvudsakliga verktyget för att uppfylla Open Closed Principle samt för att skapa lösa kopplingar (loose coupling). Principen är atthögnivå klasser ska inte bero på lågnivåklasser eller att abstraktioner skall inte bero på detaljer, detaljer skall bero på abstraktioner.



Det är vanligt att man, för att förenkla ett problem, bryter ner ett stort arbete till ett antal mindre. Man ska då alltså inte, som vanligt är, låta huvudklassen bero på de klasser man använder för att bryta ner problemet. Ett exempel på design som bryter mot principen:




1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ElevatorController
{
private ElevatorEngine _elevatorEngine;
private int _currentFloor;
public ElevatorController()
{
_elevatorEngine = new ElevatorEngine();
_currentFloor = 0;
}
public GetElevator(int calledFromFloor)
{
_elevatorEngine.MoveNumberOfFloors(calledFromFloor - _currentFloor);
}
}
Här beror högnivåklassen ElevatorController på lågnivåklassen ElevatorEngine. Det finns flera sätt att bryta detta beroende, t.ex. Service Locator ellerDependency Injection. Jag föredrar Dependency Injection då jag tycker det är enklare och snyggare. Det man då gör för att lösa beroendet är att skapa ett interface, IElevatorMover, samt att injicera en instans av ElevatorEngine, företrädes vis genom konstruktor parametrar, in i ElevatorController. Exempel:




1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ElevatorController
{
private IElevatorMover _elevatorMover;
private int _currentFloor;
public ElevatorController(IElevatorMover elevatorMover)
{
_elevatorMover = elevatorMover;
_currentFloor = 0;
}
public GetElevator(int calledFromFloor)
{
_elevatorMover.MoveNumberOfFloors(calledFromFloor - _currentFloor);
}
}
Nu beror inte ElevatorController på någon lägre klass, vi har bara sagt att vi behöver ett objekt som har en metod MoveNumberOfFloors(int). På så sätt kanElevatorEngine och ElevatorController ändras helt oberoende av varandra så länge som IElevatorMover är konstant. Det är också uppenbart att vi inte alls behöver en en ElevatorEngine utan vi kan använda en ElevatorMoveByHandeller vad som helst. Ytterligare en fördel med att lösa upp beroendet på detta sätt är att det blir enklare att ersätta lågnivåklasserna med mock objekt för att förenkla (möjliggöra) enhetstester.



För att skapa nya instanser av klasser som kräver Dependency Injection kan man använda en Inversion Of Control Container som med hjälp en uppsättning regler injicerar objekt där det behövs.



Har vi råd att skriva SOLID kod?

Allt detta är nog bra i teorin men i verkligheten har man alltid en deadline att passa. Jag har oräkneliga gånger hört utvecklare säga “Vi har inte tid att göra detta bra nu, vi måste leverera i tid” eller “Jag gjorde ett fulhack men jag ska fixa det senare” Har man tur kommer tillägget “Jag lade det i backloggen“. Problemet är bara att man aldrig får tid senare. En produktägare ser sällan vinsten med att fixa något som redan är implementerat, det är upp till oss som utvecklare att se till att vi levererar bra kod från början. Många utvecklare vill dessutom skriva bra kod, man känner sig olustig när man måste lämna ifrån sig något som inte är bra. Hur ska man då kunna motivera att man tar mer tid på sig och gör bra design?



Martin Fowler har skapat Design Stamina Hypothesis,hypotesen om designens uthållighet. Den beskrivs bäst med följande bild:



Design Stamina Hypotesis, Martin Fowler (http://martinfowler.com/bliki/DesignStaminaHypothe...)




Hypotesen säger alltså att det finns en tid i projektet då man tjänar på att inte designa (eller för den delen enhetstesta) sin kod. Men efter en viss punkt kommer det att ta längre och längre tid att utveckla ny eller förändrad funktionalitet, projektet har således låg uthållighet. Detta är bara en hypotes, Martin Fowler menar att den är omöjlig att bevisa då man inte kan mätaeffektiviteten av mjukvaruutveckling, men det finns många exempel (jag har själv sett flera) på att detta verkligen inträffar.



Ward Cunningham betecknar skillnaden mellan bra och dålig kod som teknisk skuld, något som kanske hjälper produktägare att förstå. Det kan vara helt OK att inledningsvis försätta sig i skuld för att snabbt komma ut på marknaden men om man inte betalar av sin skuld kommer man till slut att behöva alla resurser på att betala ränta (d.v.s. fixa buggar).



Mitt förslag på att få tid till att skapa bra kod är att förklara detta fenomen (gärna med hjälp av trädgårdsmetaforen) och begära lite mer tid än tidigare för varje uppgift. Sedan ser man till att lösa uppgiften genom att skriva SOLID kod samt att städa lite runt omkring den del man jobbar i. Man ska inte göra för stora förändringar men om man hela tiden ser till att göra små förbättringar för varje ändring man gör får man till slut bra kod i hela projektet.



Länkar för fördjupning

Agile Software Development, Principles, Patterns, and Practices, Robert C. Martin



Design Principles and Design Patterns, Robert C. Martin



Principles of OOD, Robert C. Martin



SOLID Object-Oriented Design, Sandi Metz



Design Stamina Hypotesis, Martin Fowler



//Martin Wåger

06 Maj
User Story Mapping

User Story Mapping

Uttrycket User Story Mapping myntades av Jeff Patton i boken med samma titel. En så kallad User Story Map är i grunde...

24 Apr
Mitt traineeprogram

Mitt traineeprogram

När jag först steg in på kontoret min första dag i början på september förra året visste jag inte riktigt vad jag sku...

02 Apr
Övergången från React till React Native

Övergången från React till React Native

För ett par veckor sedan bestämde jag mig för att lära mig React Native. Jag ville testa att bygga en mini-app som hä...