Нарушает ли OCP API, основанный на наследовании? Может ли это быть достигнуто с помощью модели поставщика / внедрения зависимости?
Я разрабатываю API впервые и пытаюсь следовать руководству SOLID. Одна из вещей, с которыми я сталкиваюсь, - это баланс между OCP и тестируемостью с простотой и легкостью расширяемости.
Этот API с открытым исходным кодом предназначен для научного моделирования и вычислений. Цель состоит в том, чтобы различные группы могли легко импортировать свои конкретные модели в эту "подключаемую" архитектуру. Таким образом, его успех как проекта будет зависеть от легкости, с которой эти ученые могут передавать свои предметно-ориентированные знания без ненужных накладных расходов или слишком крутой кривой обучения.
Например, наш механизм вычислений опирается на "векторизованные" вычисления - нам редко требуется вычислять только одно скалярное значение. Многие модели могут использовать это в своих интересах и выполнять вычисления "накладных расходов" для повторного использования в каждой скалярной подвычиске. НО, я бы хотел, чтобы пользователь мог определять простую скалярную операцию, которая будет наследовать (или иным образом обеспечиваться) поведение векторизации по умолчанию.
Моими целями было сделать это
1) как можно проще для начинающего пользователя реализовать свою базовую вычислительную модель 2) как можно проще для опытного пользователя переопределить поведение векторизации
... конечно, поддерживая SoC, тестируемость и т. д.
После нескольких ревизий у меня появилось что-то простое и объектно-ориентированное. Контракты вычислений определяются через интерфейсы, но пользователям рекомендуется наследовать абстрактный класс ComputationBase, который будет обеспечивать векторизацию по умолчанию. Вот уменьшенное представление дизайна:
public interface IComputation<T1, T2, TOut>
{
TOut Compute(T1 a, T2 b);
}
public interface IVectorizedComputation<T1, T2, TOut>
{
IEnumerable<TOut> Compute(IEnumerable<T1> a, IEnumerable<T2> b);
}
public abstract class ComputationBase<T1, T2, TOut> :
IComputation<T1, T2, TOut>,
IVectorizedComputation<T1, T2, TOut>
{
protected ComputationBase() { }
// the consumer must implement this core method
public abstract TOut Compute(T1 a, T2 b);
// the consumer can optimize by overriding this "dumb" vectorization
// use an IVectorizationProvider for vectorization capabilities instead?
public virtual IEnumerable<TOut> Compute(IEnumerable<T1> a, IEnumerable<T2> b)
{
return
from ai in a
from bi in b
select Compute(ai, bi);
}
}
public class NoobMADCalculator
: ComputationBase<double, double, double>
{
// novice user implements a simple calculation model
// CalculatorBase will use a "dumb" vectorization
public override double Compute(double a, double b)
{
return a * b + 1337;
}
}
public class PwnageMADCalculator
: ComputationBase<double, double, double>
{
public override double Compute(double a, double b)
{
var expensive = PerformExpensiveOperation();
return ComputeInternal(a, b, expensive);
}
public override IEnumerable<double> Compute(IEnumerable<double> a, IEnumerable<double> b)
{
foreach (var ai in a)
{
// example optimization: only perform this operation once
var expensive = PerformExpensiveOperation();
foreach (var bi in b)
{
yield return ComputeInternal(ai, bi, expensive);
}
}
}
private static double PerformExpensiveOperation() { return 1337; }
private static double ComputeInternal(double a, double b, double expensive)
{
return a * b + expensive;
}
}
Для векторизованного Compute в ComputationBase я изначально использовал шаблон поставщика (через конструктор DI), но скалярный Compute оставил абстрактным. Обоснованием было то, что это была хорошая "защищенная вариация" - базовый класс всегда будет "владеть" операцией векторизации, но делегировать вычисления введенному поставщику. Это также казалось в целом полезным с точки зрения повторного использования тестируемого и векторизованного кода. У меня были следующие проблемы с этим, однако:
1) Неоднородность подходов для скалярных (наследование) и векторных (поставщик) вычислений, казалось, могла запутать пользователя, казалась слишком сложной для требований и просто имела неприятный запах кода.
2) Создание "отдельного" провайдера для векторизации было дырявой абстракцией - если бы провайдер делал что-то умное, ему, как правило, требовались бы знания о реализации класса. Я обнаружил, что создаю частные вложенные классы для их реализации, что говорит мне, что это проблема, которую нельзя отделить
Является ли это хорошим подходом, когда O / R / T OCP против тестируемости против простоты? Как другие разработали свой API для расширения на разных уровнях сложности? Вы бы использовали больше механизмов внедрения зависимостей, чем я включил? Меня также интересуют хорошие общие ссылки на хороший дизайн API, а также ответы на этот конкретный пример. Благодарю.
Спасибо Дэвид
1 ответ
Если вы можете жить без наследования, вы можете просто использовать Funcs. Они предлагают простой способ обойти произвольный код и могут предложить что-то гораздо более простое. В основном это:
Func<double, double, double> pwnageComputation;//takes 2 doubles and returns one double
pwnageComputation = (num1, num2) =>
{
if (num1 + num2 > 1337)
return 1;
else if (num1 + num2 < 1337)
return -1;
return 0;
}
Func <> - это реализация лямбда-выражений, которые по сути являются обертками вокруг делегатов, чтобы их было проще использовать (по крайней мере, в C#). Таким образом, вы можете заставить своих пользователей писать специальные функции (аналогично вашему коду), но без сложности определения класса (им нужно только предоставить функцию). Вы можете узнать больше о них здесь (вторая половина) или здесь.