Одной из проблем разработки программного обеспечения является постоянное изменение. На самом деле программное обеспечение называется «ПРОГРАММНОЕ ПО», потому что ожидается, что оно будет «мягким», что означает: изменяемое, легко изменяемое. Но реальность такова, что изменить существующий программный компонент часто совсем непросто. Даже небольшие изменения могут привести к непреднамеренным побочным эффектам, которые могут вызвать волновые эффекты и привести к серьезным ошибкам. С этой точки зрения многие программные системы являются не «мягкими», а «хрупкими».

Вот почему со временем наша отрасль разработала различные методы снижения риска непреднамеренных побочных эффектов, такие как принципы SOLID, статический анализ кода и, конечно же, чрезмерное и многоуровневое автоматизированное тестирование.

В F# есть еще один метод, который мне нравится использовать, чтобы избежать непреднамеренных побочных эффектов изменений в дизайне: Размеченные объединения в сочетании с мощным сопоставлением с образцом и ограничительным компилятором F#.

Пример на F#

Представьте, что мы хотим проанализировать и смоделировать трассировку стека исключения следующим образом:

at Company.Product.ExceptionPreProcessor.GetExceptionType(String errorMessage)
at Company.Product.ExceptionPreProcessor.ExtractException(String errorMessage)
at Company.Product.TestStackTraceRemoval.IsSame(String data1, String data2)
at Company.Product.IntegrationTests.TestAbstractions.ExceptionHelper.PrepareException(string data1, string data2)
at Company.Product.IntegrationTests.TestStackTraceRemovalTests.NoTestStackTrace()";

Анализируя его пространство имен и имя класса, мы можем классифицировать каждую строку трассировки стека на тестовый класс, тестовую абстракцию и код продукта. В F# мы будем использовать размеченные союзы для моделирования этих случаев:

type CodeType = { Namespace : string; ClassName : string; Method : string }

type StackTraceLine =
    | TestClass of CodeType
    | TestAbstraction of CodeType
    | ProductCode of CodeType

Мы будем работать с таким типом объединения, используя сопоставление с образцом.

let IsStackTraceLineFromTest line =
    match line with
    | TestClass _ -> true
    | TestAbstraction _ -> true
    | ProductionCode _ -> false

Обратите внимание, что я намеренно явно назвал все случаи объединения вместо того, чтобы указать случай по умолчанию.

Пока так легко.

Теперь представьте следующую трассировку стека:

System.ApplicationException : That was unexpected
at Company.Product.Algorithms.Core.AlgorithmInterfaceProxy.Invoke(IMessage msg)
at System.Runtime.Remoting.Proxies.RealProxy.PrivateInvoke(MessageData& msgData, Int32 type)
at Company.Product.Algorithms.IntegrationTests.ITestAlgoAsync.InitializeAsync()
at Company.Product.Algorithms.IntegrationTests.AlgorithmInterfaceProxyTests.AsyncInitFailsTest()
at System.RuntimeMethodHandle.InvokeMethod(Object target, Object[] arguments, Signature sig, Boolean constructor)
at System.Reflection.RuntimeMethodInfo.UnsafeInvokeInternal(Object obj, Object[] parameters, Object[] arguments)
at System.Reflection.MethodBase.Invoke(Object obj, Object[] parameters)
at NUnit.Core.Reflect.InvokeMethod(MethodInfo method, Object fixture, Object[] args)
at NUnit.Core.TestMethod.RunTestMethod(TestResult testResult)
at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state, Boolean preserveSyncCtx)
at System.Threading.ExecutionContext.Run(ExecutionContext executionContext, ContextCallback callback, Object state, Boolean preserveSyncCtx)
at System.Threading.ThreadHelper.ThreadStart()";

Этот пример содержит дополнительные пространства имен, такие как System и NUnit, которые не соответствуют существующим категориям. Чтобы явно и правильно представить эти случаи в нашей модели, мы должны добавить по крайней мере еще один случай, например Инфраструктура или Другое.

type StackTraceLine =
    | TestClass of CodeType
    | TestAbstraction of CodeType
    | ProductCode of CodeType
    | Other 

Такое изменение курса является критическим изменением, поскольку существующий код еще не готов для обработки четвертого случая. К счастью, благодаря возможностям сопоставления шаблонов F# и компилятора F# это изменение является изменением, нарушающим компиляцию: при добавлении четвертого случая компилятор F# немедленно жалуется на все существующие сопоставления шаблонов, которые еще не обрабатывают четвертый случай. Фактически это означает, что компилятор F# просто отказывается компилировать проект до тех пор, пока все варианты использования типа union не будут корректно адаптированы. Тот факт, что компилятор «заставляет» нас пересматривать и адаптировать весь код, затронутый этим изменением, значительно снижает риск непреднамеренных побочных эффектов.

Можем ли мы смоделировать такой дизайн на C#?

Несмотря на то, что за последние годы в C# были приняты некоторые концепции функционального программирования, такие как функции высшего порядка (LinQ), типы записей и даже базовое сопоставление с образцом, в нем (пока) нет размеченных объединений, сравнимых с F#.

К счастью, мы можем довольно хорошо имитировать такие типы объединений с помощью небольшого дополнительного кода, используя наследование, сопоставление шаблонов C# и функции более высокого порядка.

public abstract record StackTraceLine(string NameSpace, string ClassName, string Method)
{
    public T Select<T>(
        Func<TestClassStackTraceLine, T> case1, 
        Func<TestAbstractionStackTraceLine, T> case2, 
        Func<ProductStackTraceLine, T> case3) =>
        
        this switch
        {
            TestClassStackTraceLine l => case1(l),
            TestAbstractionStackTraceLine l => case2(l),
            ProductStackTraceLine l => case3(l),
            _ => throw new NotSupportedException()
        };
}

public record TestClassStackTraceLine(string NameSpace, string ClassName, string Method)
    : StackTraceLine(Value, NameSpace, ClassName, Method);

public record TestAbstractionStackTraceLine(string NameSpace, string ClassName, string Method)
    : StackTraceLine(Value, NameSpace, ClassName, Method);

public record ProductStackTraceLine(string NameSpace, string ClassName, string Method)
    : StackTraceLine(Value, NameSpace, ClassName, Method);

Мы используем типы записей, чтобы получить семантику значений каждого случая нашего смоделированного типа объединения, и мы используем наследование для моделирования различных случаев.

К сожалению, сопоставление с образцом C# еще не так сильно, как аналогичная часть F#. Вместо того, чтобы жаловаться на конкретные необработанные случаи, компилятор C# «вынуждает» нас добавлять «случай по умолчанию», что противоположно тому, чего мы пытаемся достичь. Чтобы смягчить эту уязвимость, мы добавляем API Select, который будет использоваться вместо сопоставления с образцом C#.

Если мы теперь добавим четвертый случай, сигнатура API SelectApi должна быть изменена, что приведет к тому, что компилятор C# заставит нас снова просмотреть и явно принять все существующие варианты использования.

Я успешно использовал эту технику проектирования в различных проектах F# и C#, и, на мой взгляд, она определенно стоит дополнительного кода и минимальной дополнительной сложности.

Каково твое мнение?