Wie is er bang voor adapterfuncties?

Eén van de belangrijkste eigenschappen van goede code is leesbaarheid.1 Leesbare code communiceert duidelijk zijn intentie en is eenvoudig te begrijpen. Leesbare code is onderhoudbare code, onderhoudbare code is eenvoudig aanpasbare code, eenvoudig aanpasbare code is kwalitatief hoogstaande code. (Zie ook deze blog.)

Code structureren als een pipeline is een van mijn favoriete manieren om de leesbaarheid van code te bevorderen. (Zie bijvoorbeeld deze blog. Voor een uitgebreide inleiding, zie dit praatje van Scott Wlaschin.) In een pipeline wordt een stuk logica uitgeprogrammeerd als een reeks opeenvolgende datatransformaties, waarbij de output van de ene transformatie dient als input voor de volgende. Wie wil begrijpen wat de code doet, hoeft alleen maar de code van boven naar beneden te lezen.

Zoekopdracht

Laatst deed zich een mooie gelegenheid voor om een pipeline te introduceren. Onze code moest een zoekopdracht die een gebruiker invoerde, omzetten naar iets wat onze zoekindex zou kunnen interpreteren. (Zie ook deze blog.) De code verving een aantal karakters op basis van verschillende regular expressions (RegEx), zette spaties om naar +‘jes, moest sommige combinaties van zoekwoorden met haakjes (") omarmen.

De code deed wat het moest doen, maar verdiende geen schoonheidsprijs.2

static string ToSearchIndexKeyWords(string input)
{
    var cleanedKeyWords = 
        RegExC().Replace(
            RegExB().Replace(
                RegExA().Replace(input, "-"),
            "|"), 
        "|")
        .Replace(' ', '+');
    var cleaned = string.Join("|", cleanedKeyWords
        .Split("|")
        .Select(keyword => RegExD().IsMatch(keyword) 
            ? $"\"{keyword}\"" 
            : keyword));
    return cleaned;
}

Pyramid of doom

Het eerste wat me opviel waren de geneste aanroepen naar de verschillende RegEx-methodes, die bovendien, geheel tegen je gebruikelijke intuïtie in, binnen naar buiten dienden te worden gelezen. Het is een klassiek voorbeeld van een pyramid of doom. Zulke structuren bieden over het algemeen een goede gelegenheid om pipelines te introduceren.

Hoe zouden we dit aan moeten vliegen? Het eerste wat ik deed, was de geneste structuur uit elkaar halen om een beter overzicht van de code te verkrijgen.

var tempA = RegExA().Replace(input, "-");
var tempB = RegExB().Replace(tempA, "|");
var tempC = RegExC().Replace(tempB, "|");
var cleanedKeyWords = tempC.Replace(' ', '+');
var cleaned = string.Join("|", cleanedKeyWords
    .Split("|")
    .Select(keyword => RegExD().IsMatch(keyword) 
        ? $"\"{keyword}\"" 
        : keyword));
return cleaned;

Zo onder elkaar gezet, ziet de code er al een stuk netter uit, vind je niet?

Opschonen

Laten we, nu we toch bezig zijn met het uit elkaar halen van zaken, het opsplitsen en tussen haakjes zetten van de cleanedKeyWords op een logischer plek zetten – weg uit de aanroep naar string.Join.

var tempA = RegExA().Replace(input, "-");
var tempB = RegExB().Replace(tempA, "|");
var tempC = RegExC().Replace(tempB, "|");
var cleanedKeyWords = tempC
    .Replace(' ', '+')
    .Split("|")
    .Select(keyword => RegExD().IsMatch(keyword) 
        ? $"\"{keyword}\"" 
        : keyword);
var cleaned = string.Join("|", cleanedKeyWords);
return cleaned;

We beginnen al duidelijk de contouren van een pipeline te zien. Maar laten we eerst ten behoeve van de leesbaarheid de logica in de Select naar een aparte methode verhuizen. Dat stelt ons in staat om ons zonder afleiding te focussen op die drie transformaties bovenaan de methode.

var tempA = RegExA().Replace(input, "-");
var tempB = RegExB().Replace(tempA, "|");
var tempC = RegExC().Replace(tempB, "|");
var cleanedKeyWords = tempC
    .Replace(' ', '+')
    .Split("|")
    .Select(QuoteIfD);
var cleaned = string.Join("|", cleanedKeyWords);
return cleaned;

static string QuoteIfD(string keyword) =>
    RegExD().IsMatch(keyword) 
        ? $"\"{keyword}\"" 
        : keyword;

Adapter

We zouden graag van de tijdelijke variabelen af willen. We zouden graag de input van de ene aanroep naar Replace direct door willen geven aan de volgende aanroep naar Replace. Maar dat is op dit moment onmogelijk. Om een pipeline te kunnen vormen, moeten we een methode Replace aan kunnen roepen op een string (zoals ook op tempC gebeurt). Maar de Replace die we in de eerste drie regels tegenkomen, is een methode op de Regex-class en verwacht een string als input parameter. De in- en outputs van de functies sluiten niet op elkaar aan, waardoor een pipeline uitgesloten is.

Maar dat betekent niet dat we op hoeven te geven. We hebben als programmeurs de macht om de in- en outputs van functies op elkaar aan te sluiten. We kunnen een functie definiëren die de signatuur van de huidige functie omzet naar de signatuur van de gewenste functie: een adapter.

static string Replace(
    this string input, 
    Regex regex, 
    string replacement) =>
    regex.Replace(input, replacement);

Dat stelt ons in staat de code zo te herschrijven:

var cleanedKeyWords = input
    .Replace(RegExA(), "-")
    .Replace(RegExB(), "|")
    .Replace(RegExC(), "|")
    .Replace(' ', '+')
    .Split("|")
    .Select(QuoteIfD);
var cleaned = string.Join("|", cleanedKeyWords);
return cleaned;

Resultaat

En wat we met RegEx kunnen doen, kunnen we ook met string.Join.

static string Join(
    this IEnumerable<string> input, 
    string separator) =>
    string.Join(separator, input);

Wat ons uiteindelijk een hartstikke mooie pipeline oplevert, die van boven naar beneden precies vertelt wat ‘ie bij elke stap doet:

static string ToSearchIndexKeyWords(string input) =>
    input.Replace(RegExA(), "-")
        .Replace(RegExB(), "|")
        .Replace(RegExC(), "|")
        .Replace(' ', '+')
        .Split("|")
        .Select(QuoteIfD)
        .Join("|");

Ik ben geneigd dit een vooruitgang te noemen ten opzichte van de oorspronkelijke implementatie, wat jij?

Wie is er bang voor adapters?

Adapters zijn een bekend ontwerppatroon in de softwareontwikkeling. Toch kwam deze oplossingsrichting niet onmiddellijk bij me op toen ik de code begon te refactoren (om van de oorspronkelijke schrijver nog maar te zwijgen!). In plaats daarvan gaven onze eerste pogingen er blijk van koste wat kost te willen blijven werken met de “standaard”-functies.3 – Waarom?

Ik denk dat het antwoord in de volgende richting moet worden gezocht: adapters voegen geen functionaliteit toe. Het is mogelijk om de code werkend te krijgen met gebruik van de bouwblokken die de standaardclasses in je ecosysteem je bieden. En de code werkend krijgen is eerst en vooral onze taak als ontwikkelaar. Zonder werkende code heeft ons werk immers geen bestaansrecht.

Die focus op functionaliteit werpt een schaduw over andere verantwoordelijkheden die we hebben, zoals het leesbaar (en dus onderhoudbaar) maken van de werkende code. Wie een te sterke focus heeft op functionaliteit, ziet alle code die “slechts” ten behoeve van leesbaarheid wordt geïntroduceerd als overhead – het introduceren van code omwille van de code.

Maar de nadruk op werkende code is te eenzijdig. De waarde die je toevoegt als ontwikkelaar is niet alleen directe waarde voor de gebruikers van je applicatie, maar ook indirecte waarde door het systeem op zo’n manier te ontwikkelen dat deze eenvoudig aan te passen is als gebruikers om nieuwe functionaliteit vragen. Ook ontwikkelaars zijn stakeholders van het systeem. Ons belang is de code zodanig op te zetten dat deze prettig is en blijft om mee te werken. Zo zorgen we dat we de andere stakeholders het best kunnen (blijven) bedienen.


  1. Clean Code van Robert C. Martin was het eerste boek dat me daarop wees; het was mijn favoriete boek van 2020. The Art of Readable Code van Dustin Boswell en Trevor Foucher las ik niet lang daarna en werd nogal ondergesneeuwd door die eerste kennismaking, maar is ook een aanrader. ↩︎

  2. De daadwerkelijke inhoud van de operaties is voor deze blog minder van belang. De verschillende soorten RegEx heb ik daarom RegExA, RegExB etc. genoemd. Deze namen verwijzen naar methods die zijn gedecoreerd met het GeneratedRegexAttribute. Ik heb de code hier en daar uit elkaar getrokken en versimpeld voor de leesbaarheid. ↩︎

  3. Je zou het kunnen omschrijven als een soort primitive obsession, maar dan buiten de gebruikelijke context van domeinmodellen en functie-argumenten. ↩︎

code lezen · intentie van code · functioneel programmeren · ontwerppatronen · pipeline-oriented programming · primitive obsession · refactoren · software ontwikkelen · waarde