Одной из проблем разработки программного обеспечения является постоянное изменение. На самом деле программное обеспечение называется «ПРОГРАММНОЕ ПО», потому что ожидается, что оно будет «мягким», что означает: изменяемое, легко изменяемое. Но реальность такова, что изменить существующий программный компонент часто совсем непросто. Даже небольшие изменения могут привести к непреднамеренным побочным эффектам, которые могут вызвать волновые эффекты и привести к серьезным ошибкам. С этой точки зрения многие программные системы являются не «мягкими», а «хрупкими».
Вот почему со временем наша отрасль разработала различные методы снижения риска непреднамеренных побочных эффектов, такие как принципы 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#, и, на мой взгляд, она определенно стоит дополнительного кода и минимальной дополнительной сложности.
Каково твое мнение?