The art of extension methods

I den här skriften kommer vi att titta på ett par olika scenarion där C# extension methods (https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/extension-methods) passar bra, istället för att till exempel bryta ut gemensamma funktioner till en basklass och ärva.

Det är inte i alla lägen som extension methods passar såklart, men om man får till dom så är det extremt elegant. Man kan även använda dessa konstruktioner för att öka läsbarheten hos den kod som ligger närmare domänen i vilken det aktuella systemet verkar.

All källkod för exemplen nedan finns här https://github.com/HeadlightAB/TheArtOfExtensions.

Exempel 1

I det här exemplet har vi två klasser som har ganska små möjligheter att dela kod i form av gemensam basklass, iallafall utan att skriva en massa ny kod och införa arv för modeller m.m.:

public class CarParking  
{
    public List<Car> Cars { get; } = new List<Car>();

    public void ParkCar(Car car)
    {
        var index = Cars.FindIndex(x => x.RegNo == car.RegNo);
        if (index != -1)
        {
            Cars[index] = car;
        }
        else
        {
            Cars.Add(car);
        }
    }
}

public class BikeParking  
{
    public List<Bike> Bikes { get; } = new List<Bike>();

    public void ParkBike(Bike bike)
    {
        var index = Bikes.FindIndex(x => x.RegNo == bike.RegNo);
        if (index != -1)
        {
            Bikes[index] = bike;
        }
        else
        {
            Bikes.Add(bike);
        }
    }
}

Vi ser att i metoderna ParkCar respektive ParkBike gör vi samma sak med listan av fordon i den aktuella parkeringen, CarParking eller BikeParking, nämligen en "klassisk" Upsert. Upsert innebär uppdatera objektet om det återfinns annars lägg till det som nytt objekt. Visst skulle det vara fint att kunna anropa Upsert(...) på listan med fordon i stil med följande?

public void ParkCar(Car car)  
{
    Cars.Upsert(car, x => x.RegNo == car.RegNo);
}
...
public void ParkBike(Bike bike)  
{
    Bikes.Upsert(bike, x => x.RegNo == bike.RegNo);
}

Det kan vi också, om vi bygger extensionmetoden för det:

public static class EnumerableExtensiosn  
{
    public static void Upsert<T>(this List<T> target, T upsertee, Predicate<T> equals)
    {
        var idx = target.FindIndex(equals);
        if (idx == -1)
        {
            target.Add(upsertee);
        }
        else
        {
            target[idx] = upsertee;
        }
    } 
}

Vi ser också att vi skulle kunna ta det ett steg längre, att låta extensionmetoden i sig konsumera en extensionmetod, nämligen den som försöker hitta ett index och kolla om det blev -1 eller något "giltigt" index:

public static bool TryFindIndex<T>(this List<T> target, Predicate<T> equals, out int idx)  
{
    idx = target.FindIndex(equals);

    return idx != -1;
}

public static void Upsert<T>(this List<T> target, T upsertee, Predicate<T> equals)  
{
    if(target.TryFindIndex(equals, out var idx))
    {
        target[idx] = upsertee;
    }
    else
    {
        target.Add(upsertee);
    }
}

I TryFindIndex utnyttjar vi även out var-deklarationen som finns i C# 7.0 och senare.

En del kanske börjar fundera på testning av klasserna som nyttjar extensionmetoderna? I det här fallet kommer det inte alls påverka testerna av dom klasserna som nyttjar metoderna, men däremot bör man såklart ha tester för extensionmetoderna, för till exempel TryFindIndex:

using Xunit;

public class EnumerableExtensionsTests  
{
    [Fact]
    public void TryFindIndex_Should_Return_False_and_MinusOne_If_NoMatch()
    {
        var aList = new List<int> {1, 2, 3, 4, 5};

        var result = aList.TryFindIndex(x => x == 6, out var noMatchIndex);

        Assert.False(result);
        Assert.Equal(-1, noMatchIndex);
    }

    [Fact]
    public void TryFindIndex_Should_Return_True_and_TheIndex_If_Match()
    {
        var aList = new List<int> { 1, 2, 3, 4, 5 };

        var result = aList.TryFindIndex(x => x == 4, out var matchIndex);

        Assert.True(result);
        Assert.Equal(3, matchIndex);
    }
}

Exempel 2

I det här exemplet bygger vi ett par extensionmetoder för att få läsbar kod, så att den liknar någon slags fluent syntax. Vi går direkt på enhetstesterna för extensionmetoderna för att visa dess användning:

/// The extension methods
public static class FloatingPointNumbersExtensions  
{
    public static int AsTruncatedInteger(this float value)
    {
        return (int) Math.Truncate(value);
    }

    public static int AsRoundedInteger(this float value)
    {
        return (int) Math.Round(value);
    }
}

// The unit tests
public class FloatingPointsNumberExtensionsTests  
{
    [Fact]
    public void AsTruncatedInteger_Should_Return_Integer()
    {
        const float someFloat = 1.9876f;
        const int expected = 1;

        var result = someFloat.AsTruncatedInteger();

        Assert.Equal(expected, result);
    }

    [Fact]
    public void AsRounded_Should_Return_LowInteger()
    {
        const float someFloat = 1.2345f;
        const int expected = 1;

        var result = someFloat.AsRoundedInteger();

        Assert.Equal(expected, result);
    }

    [Fact]
    public void AsRounded_Should_Return_HighInteger()
    {
        const float someFloat = 1.98765f;
        const int expected = 2;

        var result = someFloat.AsRoundedInteger();

        Assert.Equal(expected, result);
    }
}

Med dom här två extensionmetoderna kan man få till en tydlig och lättläst kod, i stil med:

var a = 10;  
var b = 1.2;  
var c = (a / b).AsTruncatedInteger();  
var d = ((a + c) / b).AsRoundedInteger();  

Det råder ingen tvekan om vilken datatyp c och d har i exemplet ovan, enkelt, tydligt och elegant.

Att ta det vidare

Det är i stort sett bara fantasin som sätter gränser för extension methods. En gammal kollega har tagit dom ganska långt, titta här https://github.com/dillenmeister/Emphasize. Det är ganska underhållande.