Недавно в нашей новостной ленте появились два Героя, программисты-пекари – Борис и Маркус. Борис – хороший человек и перфекционист, а Маркус – очень скромный и серый программист, не желающий выделяться. Оба стремятся к лучшему и хотят быть полезными. Но кажется, что Маркус не очень старался.
Это новая ветка – продолжение. Сегодня сюжетная линия коснется только Маркуса. Он – главный герой.
Итак, история под катом.
Оригинальный пост: Как два программиста хлеб пекли
Вступление
Я посчитал, что в оригинальном посте много внимания уделялось Борису и крайне мало Маркусу. Возможно, из-за его скромности. Пост был веселым, мне и многим, судя по комментариям, понравился. И я очень рад, что выстрелили по астронавтам архитектуры. И Маркус был в выигрышном положении. Но то был только первый залп, настало время настроить прицел. В оригинальном посте была некая уловка – что делал Борис, раскрывалось в полной мере, а что делал Маркус, оставалось в тени за внешним интерфейсом. Неявно предполагалось, что там ужасный спагетти-код.
Сегодня я попытаюсь реабилитировать положение Маркуса. Пост будет посвящен принципу YAGNI. Это просто пример использования YAGNI и основан на личном опыте. К сожалению, не так много книг, которые показывали бы на примерах, как этот принцип применять. И большинство программистов, кроме чтения книг, рождают эти навыки опытом и трудом. Я считаю, что это именно навыки работы с кодом, а не просто теория. И таким опытом неплохо было бы обмениваться. Буду рад, если узнаю от вас тоже что-то новое. Этот пост посвящен только практике и задача будет рассматриваться на языке C#. Что, к сожалению, может уменьшить потенциальную аудиторию. Но другого выхода я не вижу, потому что теория сама по себе не может быть принята оппонентами, пока они не увидят реальные возможности. UML-диаграммы классов я не люблю. А для Маркуса они, видимо, и не нужны были.
Прошу, также не очень обращать на оплошности в стиле и коде C#, т.к. я хотел бы просто показать суть подхода, как я понимаю. И не распылять ваше внимание на мелочи. Так же, не буду показывать подход TDD, не буду показывать как писать юнит-тесты, иначе даже для такой простой задачи пост оказался бы очень объемным. Хотя, TDD, конечно, внес бы еще свои коррективы в код. Но нас интересует только – настолько ли плохой код получился бы у Маркуса, если бы он использовал YAGNI, как казалось из оригинального поста.
И конечно, я буду писать тривиальные вещи. Со многими, судя по комментариям, я единомышленник и они написали бы такой пост не хуже. А некоторые даже много лучше (используя функциональный подход).
Начнем. Пройдем по всей цепочке требований. Я — Маркус. Правда, я немного другой Маркус и не в точности веду себя как предыдущий.
Требование 1
— Ребята, нам нужно, чтобы делался хлеб
Анализ
Чтобы делать хлеб, нужен метод. Что такое хлеб? Это некая сущность. Это не object, иначе его можно спутать с другими объектами. Это не int и не какой-нибудь другой встроенный или созданный тип. Итак, хлеб, это новый отдельный класс. Он имеет состояние или поведение? Лично я знаю, что он состоит из теста (муки, пшеницы или ржи…), что его можно купить в магазине и что его можно есть. Но это мои личные знания. Заказчик пока ни слова не сказал о каком-либо поведении или состоянии. А т.к. я человек ленивый, то хоть я и знаю немного больше, я и кнопку лишний раз не надавлю, без прямого указания на желаемое заказчиком.
Исходим только из требований: хлеб не имеет состояния и поведения, а также нужен метод для получения хлеба. Сам язык C# к сожалению или к счастью требует еще немного возни, а именно: определить метод в каком-либо классе. Но т.к. заказчик об этом ни слова не сказал, то не заморачиваемся с названием, не заморачиваемся с экземлярами, я пока решил делать статический метод. Если что, всегда успеем переделать. Название выбираю такое, чтобы соответствовало наиболее среднему пониманию требований. Итак, первый код:
class Bread
{
}
class BreadMaker
{
public static Bread MakeBread()
{
return new Bread();
}
}
Требование 2
— Нам нужно, чтобы хлеб не просто делался, а выпекался в печке
Анализ
Иногда заказчики так и норовят указать, как нужно что-то реализовывать, вместо того, чтобы говорить, что они хотят. Желания заказчика о реализации на меня ни физически, ни психологически не действуют. В данном случае это не требование. Заказчик никак не может проверить, где я взял хлеб. И пока не изъявил даже такого желания – проверять. Поэтому – как я получаю хлеб и ему отдаю – не его заказчиковое дело. Но я могу вежливо согласиться с требованием и радоваться, что дальше платят деньги за безделье.
Пока ничего не делаю. Но на всякий случай, помню о печке. Заказчик не указал ни печки, ни их отличия, ни их разное влияние на хлеб. Последнее тоже важно. Если бы даже заказчик указал несколько типов печей, то спешить всё равно некуда – хлеб получается одинаковый.
Но всё же сделаем приятное начальству и немного поправим код, чтобы по смыслу соответствовал. А именно: мы уже знаем, что хлеб будет печься в печи, а не покупаться в магазине. Просто переименуем метод получения хлеба:
class BreadMaker
{
public static Bread BakeBread()
{
return new Bread();
}
}
Требование 3
— Нам нужно, чтобы газовая печь не могла печь без газа
Анализ
О! Пришла новая информация. Оказывается, есть газовая печь и ее поведение отличается от других печей. Правда, тема других печей снова не раскрыта. Ну и ладно. Пусть и будут другими.
Попробуем сравнить несколько реализаций.
Реализация 1.
enum Oven
{
GasOven,
OtherOven
}
class BreadMaker
{
public static double GasLevel { get; set; }
public static Bread BakeBread(Oven oven)
{
return oven == Oven.GasOven && GasLevel == 0 ? null : new Bread();
}
}
Сразу бросается в глаза сайд-эффект. Устанавливается уровень газа отдельно от вызова BakeBread(). Раз есть разрыв, то открывается широкое поле возможностей для появления багов. Появление этих багов (жуков) может повредить нашему полю, тогда не будет пшеницы и, следовательно, хлеба.
При такой раздельной установке параметров пользователь нашего кода (а этой пользователем-жертвой можем быть и мы) вполне может забыть установить уровень газа перед запуском газовой плиты. И тогда уровень газа может остаться от предыдущей настройки печки, когда мы пекли хлеб раньше. Что приведет не непредсказуемому поведению, если мы действительно забыли.
Также видим, что свойство статическое. Что тоже очень нехорошо – у нас только один уровень газа. Но избавление от статических методов и свойств не решит проблему, описанную выше, поэтому не рассматриваем этот вариант.
Реализация 2.
enum Oven
{
GasOven,
OtherOven
}
class BreadMaker
{
public static Bread BakeBread(Oven oven, double gasLevel)
{
return oven == Oven.GasOven && gasLevel == 0 ? null : new Bread();
}
}
Довольно просто. И немного лучше, чем предыдущий вариант реализации. Но в метод BakeBread() не всегда передаются согласующиеся параметры. Для негазовых печей gasLevel не имеет смысла. И хотя метод и будет работать, но задание gasLevel для негазовых печей будет пользователей нашего кода сбивать с толку. И правильность параметров не проверяется на этапе компиляции.
Реализация 3. Чтобы согласовать параметры, печи придется сделать кажется всё же классами.
Причем обычная печет хлеб всегда, а газовая не всегда. Т.е. два класса, виртуальные методы, перегрузка. Но нужно думать, как бы так сделать у них модификаторы доступа, чтобы печи не создавали сами по себе, а пользовались моим методом BakeBread(), иначе, появятся сайд-эффекты.
И тут меня (Маркуса) осеняет! На данном этапе достаточно сделать так:
class BreadMaker
{
public static Bread BakeBreadByGasOven(double gasLevel)
{
return gasLevel == 0 ? null : new Bread();
}
public static Bread BakeBreadByOtherOven()
{
return new Bread();
}
}
И действительно, заказчик пока не обмолвился ни словом, как мы будем использовать печи. Такой код на данном этапе вполне удовлетворяет.
Требование 4
— Нам нужно, чтобы печки могли выпекать ещё и пирожки (отдельно — с мясом, отдельно — с капустой), и торты.
Анализ
Да не вопрос! А если устанавливать в печке уровень температуры, так мы и мороженное сможем в ней выпекать. Шутка. Я, Маркус, стараюсь быть серьезным, — про температуру ни слова. Кто вас, заказчиков знает ))
Итак, пирожки и торты. Мало того, пирожки двух видов. Но это, мы из жизни знаем, что у пирожка с мясом и у пирожка с капустой больше общего, чем у торта. Но в контексте задачи заказчик об этом не говорил. Он не сказал, что мы будем как-то группировать пирожки отдельно, торты отдельно. Поэтому, пока, исходя из требований – торт ведет себя почти как пирожок с вишнями – все они равноправны. Поведение у них есть? Нет. Состояние есть? Нет. Значит, чтобы их отличать друг от друга, нам вполне достаточно завести перечисление. А забегать наперед, угадывая желания заказчика, которые возникнут завтра, мы принципиально не хотим. Значит – перечисление – самое верное. Скорее всего. Не уверен. Но и не надо. Всегда можно будет переписать, если что.
Параллельно меняем названия, теперь печем не хлеб, а хлебобулочные изделия.
public enum BakeryProductType
{
Bread,
MeatPasty,
CabbagePasty,
Cake
}
public class BakeryProduct
{
public BakeryProduct(BakeryProductType bakeryProductType)
{
this.BakeryProductType = bakeryProductType;
}
public BakeryProductType BakeryProductType { get; private set; }
}
class BakeryProductMaker
{
public static BakeryProduct BakeByGasOven(BakeryProductType bakeryProductType, double gasLevel)
{
return gasLevel == 0 ? null : new BakeryProduct(bakeryProductType);
}
public static BakeryProduct BakeByOtherOven(BakeryProductType breadType)
{
return new BakeryProduct(breadType);
}
}
Требование 5
— Нам нужно, чтобы хлеб, пирожки и торты выпекались по разным рецептам
Анализ
Бегло взглянув на код, замечаем, что у нас есть прекрасное перечисление BakeryProductType. Называется оно как-то коряво, как-то по програмистски, не близко к предметной области. Зато, ведет себя как рецепт. Во как бывает! Хлеба и булки пекутся по рецептам, а не по типу. И у нас в конструктор булки попадает все таки, наверное, рецепт. Достаточно переименовать. Единственный непорядок – это свойство-тип «булки». Но я бы смирился. Глядя механически на код и представляя себе предметную область, как некие множества, то я не вижу особой разницы между рецептом и типом. Т.е. рецепт – это прямая причина того, что получится впоследствии. Конечно, в жизни, мы знаем о рецептах немного больше – они не только описывают, что получится. Они так же содержат алгоритм получения. Но кого это волнует? Заказчик говорил об этом? Нет. Значит, в контексте задачи такого и не было. Будет нужен алгоритм – привяжем потом, придумаем что-нибудь.
Поэтому я мирюсь с тем, что свойство останется типом, а перечисление – рецептом. Не создавать же кучу наследников или другое перечисление из-за свойств нашего языка. В контексте задачи всё точно. Хотя и не очень красиво. Компромисс?
public enum Recipe
{
Bread,
MeatPasty,
CabbagePasty,
Cake
}
public class BakeryProduct
{
public BakeryProduct(Recipe recipe)
{
this.BakeryProductType = recipe;
}
public Recipe BakeryProductType { get; private set; }
}
class BakeryProductMaker
{
public static BakeryProduct BakeByGasOven(Recipe recipe, double gasLevel)
{
return gasLevel == 0 ? null : new BakeryProduct(recipe);
}
public static BakeryProduct BakeByOtherOven(Recipe recipe)
{
return new BakeryProduct(recipe);
}
}
Требование 6
— Нам нужно, чтобы в печи можно было обжигать кирпичи
Анализ
Если совсем буквально следовать этому требованию и всем записанным требованиям, то кирпич ничем не отличается от торта или хлеба. Самое смешное, что кирпич у нас сильно больше отличается от пирожка с повидлом, потому что у нас его нет в требованиях, а от пирожка с мясом так себе. Так же, как от хлеба. Поэтому, это требование по YAGNI, сильно утрированно, реализуется всего лишь расширением перечисления рецептов с переименованием всех классов – булки в «продукт печи», коим является и кирпич тоже и т.д. Весь смысл того, как создавать архитектуру классов, заключается в том, как это будут использовать. Именно от того, что считать общим (т.е. состояние и поведение базового класса), а что частным (состояние и поведение наследников). Если ни того, ни другого нет, то можно и перечисление. Не страшно не угадать. Перечисление в класс и наследники легко превращается.
Кто-то из вас увидел ужас в коде? Может такой код сложно тестировать? Да, вроде, код Бориса значительно сложнее тестировать. Объем больше, больше тестов. Больше функционала, чем требуется? Больше тестов.
Конечно, по всей видимости, в оригинальном посте подразумевалось, что требования были более подробными и каждая фраза уточнялась подробными объяснениями. Но жанр YAGNI требует не додумывать.
Давайте дальше поиграем в требования.
Требование 7
— Как вы не досмотрели? Не каждая печь может жечь кирпичи. Для этого нужна специальная печь.
Анализ
Ну и ладно. Убираем из перечисления (рецептов?) кирпич и возвращаем имена. Создаем отдельный пустой класс Brick. И новый метод:
public static Brick MakeBrickByFurnace()
{
return new Brick();
}
Кстати, обилие методов, где каждый производит точно какой-то объект, лучше, чем некий гибкий способ создания объектов, если гибкость не требуется прямо сейчас. Если гибкость не требуется, то программа должна позволять меньше, быть более ограниченной. Мы сейчас не рассматриваем юнит-тесты, где часто удобно заменять объекты конкретных типов интерфейсами. Весь этот код легко при случае переводится на интерфейсы. Да и C# с его отражением не очень требователен в тестировании к каким-то развязкам.
Далее, заказчик решил играть против Маркуса.
Требование 8
— Каждый рецепт должен содержать продукты и их количество (вес). Рецепты в требовании прилагаются.
Анализ
Первая кровь, которую так ждал Борис.
Попробуем справиться со страшным спагетти-кодом, который должен был у нас давно образоваться и не дать нам никаких шансов рефакторить. Так ли это?
Продукты для рецепта – очевидно – перечисление. Сам рецепт уже содержит не только название того, что он создаст (или что то же самое, название себя), но и набор продуктов с их количеством. Но при этом, замечаем, что с конкретным рецептом связан строгий набор продуктов и он не меняется. (Еще раз вспоминаем, что пост о YAGNI – никаких «а вдруг заказник захочет, чтобы менялось»! Никаких вдруг, сегодня – это сегодня, а завтра – это завтра).
Т.е. заказчик не сказал, что продукты и вес в рецепте могут меняться. Он, конечно, и не сказал, что они должны быть фиксированы. Но фиксированный случай более ограниченный и строгий. А мы выбираем всегда более ограниченные случаи. Для нас лучше не то, что гибче, а то, что проще и строже.
Да и рецепт со строгим набором продуктов – лучше соответствует личному опыту. Из этого следует, что в таком случае нецелесообразно использовать наследование и писать класс для каждого рецепта. Тогда каждый класс будет хранить просто константы.
И пару еще мыслей. Т.к. на данный момент, рецепты в коде – всего лишь заданное перечисление и оно задано еще до компиляции, то при не имении других требований, по-видимому, это поведение должно остаться. Из этого, следует, что нам должны быть доступны все рецепты и они заданы прямо в коде. Создать новый, без расширения перечисления нельзя. Отсюда, похоже, нужно делать класс Recipe, предварительно переименовав перечисление с таким именем в RecipeName. Мир такой изменчивый. Теперь перечисление всего лишь указывает на рецепт и позволяет его выбрать, но не характеризирует его в полной мере.
Чтобы удовлетворить условия выше, достаточно так:
public enum RecipeName
{
Bread,
MeatPasty,
CabbagePasty,
Cake
}
public enum RecipeProduct
{
Salt,
Sugar,
Egg,
Flour
}
public class Recipe
{
private Recipe() { }
public RecipeName Name { get; private set; }
public IEnumerable<KeyValuePair<RecipeProduct, double>> Products { get; private set; }
private static Dictionary<RecipeName, Dictionary<RecipeProduct, double>> predefinedRecipes;
static Recipe()
{
predefinedRecipes = new Dictionary<RecipeName, Dictionary<RecipeProduct, double>>
{
{
RecipeName.Bread, new Dictionary<RecipeProduct, double>
{
{RecipeProduct.Salt, 0.2},
{RecipeProduct.Sugar, 0.4},
{RecipeProduct.Egg, 2.0},
{RecipeProduct.Flour, 50.0}
}
}
..................
};
}
public static Recipe GetRecipe(RecipeName recipeName)
{
Recipe recipe = new Recipe();
recipe.Name = recipeName;
recipe.Products = predefinedRecipes[recipeName];
return recipe;
}
}
Ничего и ломать не приходится. Для создания продукта пока всего лишь достаточно имени рецепта. Там ничего не меняем. Надо будет, передадим и сам рецепт.
В данном коде мы всего лишь из рецепта-перечисления сделали класс и связали имя рецепта с составляющими его продуктами. Надо будет последовательность действий в рецепте выразить, точно также его можно «прикрутить». Надеюсь, это понятно и никакого спагетти-кода не будет. Появится отдельное поведение у классов – легко класс Recipe становится базовым и появляются наследники. Но мы об этом не думаем. У нас YAGNI, нам такого не говорили делать. Но мы этого и не боимся.
Требование 9
Подлый заказчик, узнав про наш бесплановый подход, решил подловить.
— Хочу, чтобы рецепт мог изменяться. И печки готовили по любому рецепту, составленному поваром.
Вступаем с полемику:
— Как изменяться?
— Считаем, что повар не знает рецептов и может экспериментировать. Вы сделали рецепты фиксированными? А он хочет добавлять разное количество яиц, сахара и т.д.
— А что за булку мы в таком случае получим? Мы же должны что-то получить? Хлеб, пирожки или торт? Очевидно, если рецепты будут отличаться, то повар будет печь нечто другое.
— Я думаю, что торт бывает разный по вкусу, более сладкий, менее сладкий. Также и хлеб. Значит, в каких-то пределах могут рецепты отличаться, но мы получим какой-то продукт из списка.
— Т.е. чтобы узнать, что мы получим, нам надо искать наиболее близкий рецепт к тому списку продуктов в рецепте повара?
— Да.
Анализ
У нас есть фиксированные рецепты. Теперь рецепты могут быть не фиксированы. Но те, которые у нас есть, являются эталонными. Чтобы разрешить пользователям нашего кода создавать свои рецепты, достаточно сделать конструктор открытым. Но также необходимо дать возможность задавать продукты. Не хочется давать возможность присваивать пользователям свойство или указать конкретный тип. Иначе он сможет и повредить наши эталоны. Значит проще всего, дать возможность передавать продукты в конструктор. Это также избавит от разрыва между созданием и инициализаций и значит, уменьшит вероятность бага.
Теперь у нас два конструктора:
private Recipe() { }
public Recipe(IEnumerable<KeyValuePair<RecipeProduct, double>> products)
{
Dictionary<RecipeProduct, double> copiedProducts = new Dictionary<RecipeProduct, double>();
foreach (KeyValuePair<RecipeProduct, double> pair in products)
{
copiedProducts.Add(pair.Key, pair.Value);
}
this.Products = copiedProducts;
}
Во втором конструкторе создается копия. Это из-за свойств C# — передавать по умолчанию ссылки. У пользователя класса ссылка останется и если не сделать копию, он сможет позже менять ингредиенты рецепта. Что не входит в наши планы.
Также, в этом посте, я стараюсь поменьше не использовать лямбды и женерики, оставаясь в рамках стандартного ООП. Чтобы большей аудитории было понятно, что я делаю. Код мог бы быть написан и по-другому и проще. Но моя цель – описать сам принцип YAGNI и какие-то способы оценки кода, а не показывать разные возможности шарпа. Конечно, способы оценки зависят от языка и от его возможностей.
Второй конструктор, который для пользователей, не устанавливает значение свойства — имени рецепта. Т.к. у нас продукты передаются в конструктор и не могут меняться, то там же и вычислить как-то близость. Точнее, особо «умные» поменять смогут, но не будем страдать паранойей. Считаем, что разработчики адекватные и стоят на позиции созидания, а не разрушения.
Нужно написать какой-то метод близости. Заказчик не уточнял, поэтому напишем наиболее простой, с методом наименьших квадратов. Учитывая, что каждый ингредиент имеет разный «вес». Пока запишем какие-то веса, которые позже можно настроить.
Код приблизительно такой:
private double GetDistance(Recipe recipe)
{
Dictionary<RecipeProduct, double> weights = new Dictionary<RecipeProduct, double>();
weights[RecipeProduct.Salt] = 50;
weights[RecipeProduct.Sugar] = 20;
weights[RecipeProduct.Egg] = 5;
weights[RecipeProduct.Flour] = 0.1;
double sum = 0.0;
foreach(KeyValuePair<RecipeProduct, double> otherProductAmount in recipe.Products)
{
var productAmounts = this.Products.Where(p => p.Key == otherProductAmount.Key);
if (productAmounts.Count() == 1)
{
sum += Math.Pow(productAmounts.First().Value - otherProductAmount.Value, 2)
* weights[otherProductAmount.Key];
}
else
{
return double.MaxValue;
}
}
return sum;
}
private RecipeName GetRecipeName()
{
IEnumerable<Recipe> etalons = ((RecipeName[])Enum.GetValues(typeof(RecipeName)))
.Select(recipeName => Recipe.GetReceipt(recipeName));
IEnumerable<KeyValuePair<RecipeName, double>> recipeNamesWithDistances = etalons
.Select(e => new KeyValuePair<RecipeName, double>(e.Name, GetDistance(e)));
double minDistance = recipeNamesWithDistances.Min(rd => rd.Value);
if (minDistance == double.MaxValue)
{
throw new Exception("Подходящий рецепт не найден");
}
return recipeNamesWithDistances.First(rd => rd.Value == minDistance).Key;
}
И в вызов конструктора соответственно, добавляется присвоение имени:
public Recipe(IEnumerable<KeyValuePair<RecipeProduct, double>> products)
{
Dictionary<RecipeProduct, double> copiedProducts = new Dictionary<RecipeProduct, double>();
foreach (KeyValuePair<RecipeProduct, double> pair in products)
{
copiedProducts.Add(pair.Key, pair.Value);
}
this.Products = copiedProducts;
this.Name = GetRecipeName();
}
Надежнее было бы вычислять всегда на лету. Но тогда нужно было бы придумывать разделение присваивания имени для эталона и для наших весов. Пока этого достаточно.
Как видимо, совершенно не страшно переделать код к такому текущему требованию. Мало того, мы особо ничего и не ломали. А просто расширили. Времени на доработку не больше, чем, если бы мы заранее предугадали. Но предугадывать и не угадать – действительно страшно. Представьте себе этот, вроде простой код, по этому требованию, но сделанный раньше. Это просто монстр, требующий лишнего тестирования. А сейчас он написан обосновано.
Код не идеальный, я уже не смог не пуститься в женерики и лямбды. Так код становится меньше и чище. Надеюсь, это не сильно вредит пониманию незнакомым с C# и лямбдами читателями. Конечно, его можно еще больше сократить. Но я пытаюсь быть понятным большему числу людей.
Я здесь уже завязался на конкретный алгоритм, хотя заказчик именно этот не требовал. YAGNI это или нет? Тут мы разбираемся по ситуации. Возможно, заказчику сразу нужен видимый результат. Чаще так и бывает. Поэтому нужен хоть какой-то алгоритм. Но если позже нам понадобится другой алгоритм, ничего не стоит заменить этот на другой. Или даже написать несколько и выбирать. Или даже у пользователей кода брать делегат, который будет делать сравнение на близость.
Понятное дело, теперь нужно еще передавать не имя рецепта в методы изготовления, а сами рецепты. Т.е. вот так:
public class BakeryProduct
{
public BakeryProduct(Recipe recipe)
{
this.BakeryProductType = recipe.Name;
}
public RecipeName BakeryProductType { get; private set; }
}
И:
public static BakeryProduct BakeByGasOven(Recipe recipe, double gasLevel)
{
return gasLevel == 0 ? null : new BakeryProduct(recipe);
}
public static BakeryProduct BakeByOtherOven(Recipe recipe)
{
return new BakeryProduct(recipe);
}
Здесь рефакторинг совсем не сверхнапряжный.
Требование 10
Коварный заказчик изучил каким-то образом наш код и ищет самое больное место, к чему наш код совершенно не готов. Ведь у нас должно уже 9 раз получиться нечитаемое спагетти. Мы ж писали без плана и наш код страшно не гибкий. Видимо Борис вступил с ним в сговор и они ищут слабые места.
— А теперь мне нужно, чтобы всё, что производят печи, можно было продавать в магазине и это называлось товаром. Магазин должен помещать в себя некоторое количество товаров, у каждого товара есть цена и надо иметь возможность посчитать цену всех товаров в магазине. При этом, рабочие, которые изготавливают кирпичи и булки, не квалифицированные и не знают, как надо их изготавливать. Они просто засыпают сырье в печку (абстрактную ))), и получают продукт, т.е. товар, который везут в магазин.
Анализ
До этого, у нас печи изготавливали иногда не связанные вещи. Кирпич, например. Он не имеет общего предка с булками. И верно, откуда мы могли знать, что у них общего? Нет, мы конечно, в жизни знаем и про кирпичи и про хлеб. Но мы не могли полагать, что заказчик захочет рассматривать их позже именно как товар. Потом, у нас три разных метода, которые не очень пересекаются. Нет одного метода, который бы выпускал любую вещь. А разве должен был? Предка не было. Не должен.
Потом, иерархия классов – это скорее зло. Как и любая лишняя строчка кода. То, что у нас на данный момент до этого требования были отдельные методы, возвращающие конкретные классы – лучше, безопаснее. Представим, что мы бы сразу сделали печь, которая делает нечто через единый метод. Как делал Борис. И вот она выпускала бы Product. Что есть Product? Это базовый класс. Который имеет базовое поведение и состояние. Но нам, допустим, в пользовательском коде понадобилось сделать именно пирожок. Вот, печь, изготавливает продукт. Не пирожок. Т.е. это пирожок на самом деле, только когда мы из печи достаем, он называется продуктом. И вдруг нам понадобилось узнать, сколько в нем мяса. А это поведение в наследнике, а именно – в пирожке с мясом. Что нам в таком случае делать? Приводить ссылку на продукт к ссылке на его настоящий тип.
И тут как раз нужно ждать неприятностей. При приведении от базового типа к наследнику, компилятор не может уже проконтролировать правильность приведения. Т.е. нарушается строгость типизации. Приходится в пользовательском коде использовать отражение, узнать какой настоящий тип или пытаться привести, а далее создавать ветвления, выбрасывать эксепшины, если что не так и т.д.
Т.е. преждевременная гибкость – это не только не полезно, это вредно.
Но сейчас нам это уже понадобилось по-настоящему.
Итак, сами себе формулируем сжато требования, которые сложились на данный момент: «Булки готовятся по рецепту, кирпичи нет. Кирпичи могут изготавливаться только в специальной печи, причем в ней могут изготавливаться только кирпичи (не булки). Нужен единый механизм загрузки сырья и получения товара. Булки и кирпичи являются товаром, который имеет цену.»
Последнее реализуется просто. У булок и кирпичей появился общий предок – товар.
public abstract class Article
{
public double Price { get; private set; }
public Article(double price)
{
this.Price = price;
}
}
Наследуем классы:
public class BakeryProduct : Article
{
public BakeryProduct(Recipe recipe, double price): base(price)
{
this.BakeryProductType = recipe.Name;
}
public RecipeName BakeryProductType { get; private set; }
}
public class Brick: Article
{
public Brick(double price) : base(price) { }
}
И рефакторим вызовы методов изготовлений, передавая в конструторы цену, которую устанавливает пользователь.
Приблизительно так:
public static BakeryProduct BakeByOtherOven(Recipe recipe, double price)
{
return new BakeryProduct(recipe, price);
}
Это уже почти полдела. Остались мелочи. Но ради сокращения поста я просто опишу, что надо сделать. Нужно создать один метод, который бы возвращал товар. Для согласованности параметров необходимо сделать класс Сырье и наследники, которые будут – сырьем для булок и сырьем для кирпичей. Сырье для булок, конечно, содержит и рецепт. Классы такие необходимы, т.к. просто набором параметров (уровень газа, рецепт и т.д.) мы можем передавать странные параметры, что делает ненадежным работу программы. По сути, классы сырья – это способы согласованной упаковки параметров.
В едином методе для получения товаров мы можем в самом простом случае использовать switch и выбрать нужный метод, который уже есть, для производства того, что нужно, в зависимости от сырья. Я бы так и сделал в данном случае. При небольшом количестве элементов в перечислении это не очень засоряет код. При возрастании количества элементов можно думать о других способах. Например, об абстрактной фабрике. С помощью перегруженных методов в ней создаете сырье и печку одновременно, которая умеет работать с этим сырьем.
Как видим, нет никаких сложностей преобразовывать архитектуру на ходу, по мере поступления требований. Нет и никаких сложностей покрывать это тестами. Методы не большие. Мало того, такой код легче покрывать тестами, потому что его меньше. Код без предугадываний всегда находится в более или менее гибком состоянии во всех направлениях. При этом он достаточно жесткий. Код Бориса сполз с дистанции еще на трети пути. А этот можно преобразовывать до бесконечности. Чтобы преобразования были возможны, нужно всегда проводить рефакторинг. Принцип YAGNI говорит только о том, что нужно реализовывать только минимальный функционал. Но ни в коем случае он не говорит, что если код работает, то его не трогать. На рефакторинг и юнит-тесты принцип YAGNI не распространяется. Только тогда такая технология разработки работает.
Естественно, код в посте не идеален. Цель была только показать принцип. Я уверен, что со многими частностями в том, как я делал анализ требований, и сторонники YAGNI не будут согласны. У каждого свой личный опыт. У каждого свои методы и приемы. И это еще значительно зависит от языка программирования, потому что это средство выражения мыслей.
Автор: m36