
Recently, two heroes appeared in our news feed, bakers programmers Boris and Markus. Boris is a good person and a perfectionist, and Marcus is a very modest and gray programmer who does not want to stand out. Both strive for the best and want to be helpful. But it seems that Marcus did not try very hard.
This is a new branch - continuation. Today, the storyline touches only Marcus. He is the main character.
So, the story under the cut.
Original post:
How two programmers baked breadIntroductionI considered that in the original post much attention was paid to Boris and very little to Marcus. Perhaps because of his modesty. The post was fun, I, and many, judging by the comments, liked. And I am very glad that they shot
astronauts of architecture . And Marcus was in an advantageous position. But it was only the first volley, it was time to adjust the scope. In the original post there was a certain trick - what Boris did was fully revealed, and what Marcus did remained in the shadows behind the external interface. It was implicitly assumed that there was a terrible spaghetti code.
')
Today I will try to rehabilitate the position of Marcus. The post will be devoted to the principle of YAGNI. This is just an example of using YAGNI and is based on personal experience. Unfortunately, there are not so many books that would show with examples how to apply this principle. And the majority of programmers, besides reading books, give birth to these skills through experience and work. I believe that this is exactly the skills of working with code, and not just a theory. And such an experience would be nice to share. I would be glad if I learned something new from you too. This post is only about practice and the task will be considered in C #. That, unfortunately, can reduce the potential audience. But I see no other way out, because the theory itself cannot be accepted by the opponents until they see real possibilities. I don't like UML class diagrams. And for Marcus, they apparently were not needed.
I also ask that you don’t pay much for errors in style and C # code, since I would just like to show the essence of the approach, as I understand it. And do not spray your attention on the little things. Also, I will not show the TDD approach, I will not show how to write unit tests, otherwise even for such a simple task the post would be very voluminous. Although, TDD, of course, would add its own corrections to the code. But we are only interested in whether Marcus would have gotten so bad code if he had used YAGNI, as it seemed from the original post.
And of course, I will write trivial things. With many, judging by the comments, I am a like-minded person and they would write such a post no worse. And some are even much better (using the functional approach).
Let's start. Let's go through the entire chain of requirements. I am Marcus. True, I'm a little different Marcus and do not exactly behave like the previous one.
Requirement 1- Guys, we need bread to be made.AnalysisTo make bread, you need a method. What is bread? This is a kind of entity. This is not an object, otherwise it can be confused with other objects. This is not an int or any other built-in or created type. So bread is a new separate class. Does he have a condition or behavior? Personally, I know that it consists of dough (flour, wheat or rye ...), that you can buy it in the store and that you can eat it. But this is my personal knowledge. The customer has not yet said a word about any behavior or condition. And since I’m a lazy person, even though I know a little more, I don’t press the button again, without directly indicating what the customer wants.
We proceed only from the requirements: bread has no state and behavior, and also needs a method for obtaining bread. The C # language itself unfortunately or fortunately requires a little more fuss, namely: to define a method in any class. But since the customer did not say a word about this, then we don’t bother with the name, we don’t bother with the copies, I decided to do a static method. If anything, always have time to redo it. I choose the name to meet the most average understanding of the requirements. So, the first code:
class Bread { } class BreadMaker { public static Bread MakeBread() { return new Bread(); } }
Requirement 2- We need to not just make bread, and baked in the ovenAnalysisSometimes customers want to specify how to implement something, instead of saying what they want. The wishes of the customer about the implementation of me neither physically nor psychologically do not act. In this case, this is not a requirement. The customer can not verify where I took the bread. And until he even expressed such a desire - to check. Therefore, how I get bread and give it to him is not its customer business. But I can politely agree with the demand and be glad that they continue to pay money for idleness.
While doing nothing. But just in case, I remember about the stove. The customer did not indicate either the stove, or their differences, or their different influence on the bread. The latter is also important. Even if the customer indicated several types of ovens, there is still no hurry - the bread is the same.
But nevertheless we will make the authorities happy and slightly correct the code so that it matches the meaning. Namely: we already know that bread will be baked in the oven, and not bought in the store. Just rename the method of obtaining bread:
class BreadMaker { public static Bread BakeBread() { return new Bread(); } }
Requirement 3- We need a gas stove to not be able to stove without gasAnalysisABOUT! New information has come. It turns out that there is a gas stove and its behavior is different from other stoves. True, the topic of other furnaces is not disclosed again. Well, okay. Let them be different.
Let's try to compare several implementations.
Implementation 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(); } }
Immediately struck by the side effect. Sets the gas level separately from the BakeBread () call. Once there is a gap, then a wide field of opportunities opens up for the appearance of bugs. The appearance of these bugs (bugs) can damage our field, then there will be no wheat and, therefore, no bread.
With such a separate setting of parameters, the user of our code (and we can be this victim-user as well) may well forget to set the gas level before starting the gas furnace. And then the gas level may remain from the previous setting of the stove, when we baked bread before. Which will lead to unpredictable behavior if we really forget.
We also see that the property is static. Which is also very bad - we have only one level of gas. But getting rid of static methods and properties does not solve the problem described above, so we do not consider this option.
Implementation 2.
enum Oven { GasOven, OtherOven } class BreadMaker { public static Bread BakeBread(Oven oven, double gasLevel) { return oven == Oven.GasOven && gasLevel == 0 ? null : new Bread(); } }
Pretty simple. And a little better than the previous version of the implementation. But matching parameters are not always passed to the BakeBread () method. For non-gas furnaces gasLevel does not make sense. And although the method will work, the task of gasLevel for non-gas furnaces will confuse users of our code. And the correctness of the parameters is not checked at the compilation stage.
Implementation 3. In order to coordinate the parameters, the furnace will have to be made, it seems, by classes.
And the usual bake bread always, but gas is not always. Those. two classes, virtual methods, overload. But you need to think about how to make access modifiers so that the furnaces do not create by themselves, but use my BakeBread () method, otherwise side effects will appear.
And here it overshadows me (Marcus)! At this stage, it is enough to do so:
class BreadMaker { public static Bread BakeBreadByGasOven(double gasLevel) { return gasLevel == 0 ? null : new Bread(); } public static Bread BakeBreadByOtherOven() { return new Bread(); } }
And indeed, the customer has not yet said a word how we will use the furnaces. This code is quite satisfactory at this stage.
Requirement 4- We need the ovens to bake more pies (separately - with meat, separately - with cabbage), and cakes.AnalysisSure, not a problem! And if you set the temperature in the oven, then we can bake ice cream in it. Joke. I, Marcus, try to be serious - not a word about temperature. Who knows you, customers))
So, pies and cakes. Moreover, two kinds of patties. But this, we know from life that a meat patty and a cabbage pie have more in common than a cake. But in the context of the task the customer did not talk about it. He did not say that we would somehow group the cakes separately, the cakes separately. Therefore, for now, based on the requirements, the cake behaves almost like a pie with cherries - they are all equal. Do they have behavior? Not. Is there a condition? Not. This means that in order to distinguish them from each other, it is quite enough for us to start the transfer. And to run ahead, guessing the wishes of the customer, which will arise tomorrow, we basically do not want. This means that the listing is the most correct. Probably. Not sure. But do not. You can always rewrite, if that.
In parallel, we change names, now we bake not bread, but bakery products.
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); } }
Requirement 5- We need bread, cakes and pies baked according to different recipesAnalysisGlancing at the code, we notice that we have an excellent enumeration of BakeryProductType. It is called somehow clumsy, somehow programmatically, not close to the subject area. But behaves like a recipe. In what happens! Bread and bread are baked by recipe, not by type. And we all the same gets to the designer of the roll, probably the recipe. Enough to rename. The only disorder is the property-type "rolls". But I would be humbled. Looking mechanically at the code and imagining the subject area as some kind of set, I don’t see much difference between the recipe and the type. Those. The recipe is the direct cause of what happens afterwards. Of course, in life, we know a little more about recipes - they not only describe what happens. They also contain an acquisition algorithm. But who cares? The customer talked about this? Not. So, in the context of the task this was not. An algorithm will be needed - we will tie it later, think of something.
Therefore, I accept the fact that the property will remain a type, and the enumeration - a recipe. Do not create the same bunch of heirs or other enumeration due to the properties of our spoken language. In the context of the problem, everything is accurate. Although not very beautiful. Compromise?
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); } }
Requirement 6- We need to be able to burn bricks in the furnaceAnalysisIf you absolutely literally follow this requirement and all written requirements, then a brick is no different from a cake or bread. The funny thing is that the brick we have is much more different from the pie with jam, because we do not have it in the requirements, but from the pie with meat so-so. Just like from bread. Therefore, this requirement for YAGNI, highly exaggerated, is realized only by expanding the enumeration of recipes with the renaming of all classes - rolls into the “oven product”, which is also a brick, etc. The whole point of how to create a class architecture is how it will be used. It is from what is considered general (that is, the state and behavior of the base class), and what is private (the state and behavior of the heirs). If neither one nor the other, then you can transfer. Not scary not to guess. Enumeration in a class and successors easily turns.
Have any of you seen the horror in the code? Maybe this code is difficult to test? Yes, it seems, Boris's code is much more difficult to test. Volume more, more tests. More functionality than required? More tests.
Of course, it seems that in the original post it was implied that the requirements were more detailed and each phrase was clarified with detailed explanations. But the genre YAGNI requires not to think out.
Let's continue playing requirements.
Requirement 7- How did you not inspected? Not every furnace can burn bricks. For this you need a special oven.AnalysisWell, okay. We remove the brick from the enumeration (recipe?) And return the names. Create a separate empty Brick class. And the new method:
public static Brick MakeBrickByFurnace() { return new Brick(); }
By the way, the abundance of methods, where everyone produces exactly some kind of object, is better than some flexible way of creating objects, if flexibility is not required right now. If flexibility is not required, then the program should allow less, be more limited. We are not currently considering unit tests, where it is often convenient to replace objects of specific types with interfaces. All this code is easily converted to interfaces. Yes, and C # with its reflection is not very demanding in testing to some interchanges.
Further, the customer decided to play against Marcus.
Requirement 8- Each recipe should contain products and their quantity (weight). Recipes in the requirement attached.AnalysisThe first blood that Boris was waiting for.
Let's try to cope with the terrible spaghetti code, which should have been formed in our country for a long time and would not give us any chance of refactoring. Is it so?
Products for the recipe - obviously - listing. The recipe itself already contains not only the name of what it will create (or, what is the same thing, the name itself), but also a set of products with their quantity. But at the same time, we note that a specific set of products is associated with a specific recipe and it does not change. (Once again we recall that the post about YAGNI - no “what if the reserve wants to change”! No suddenly, today is today, and tomorrow is tomorrow).
Those. the customer did not say that the products and the weight in the recipe may vary. He, of course, did not say that they should be fixed. But the fixed case is more limited and strict. And we always choose more limited cases. For us, it is better not that it is more flexible, but that which is simpler and stricter.
Yes, and a recipe with a strict set of products - better matches personal experience. From this it follows that in this case it is inappropriate to use inheritance and write a class for each recipe. Then each class will store just constants.
And a couple more thoughts. Since at the moment, the recipes in the code are just a given enumeration and it is set before the compilation, then if there are no other requirements, it seems that this behavior should remain. From this, it follows that all recipes should be available to us and they are specified directly in the code. You cannot create a new one without an extension. From here, it seems, you need to make the Recipe class, after renaming the enumeration with this name into RecipeName. The world is so changeable. Now the listing only indicates the recipe and allows you to select it, but does not fully characterize it.
To satisfy the conditions above, it is enough like this:
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; } }
Nothing and do not have to break. For the creation of the product so far just enough recipe name. We do not change anything there. It will be necessary, and give the recipe itself.
In this code, we just made a recipe-enumeration class and associated the name of the recipe with its constituent products. It will be necessary to express the sequence of actions in the recipe, just as it can be “screwed”. I hope this is understandable and there will be no spaghetti code. There will be a separate behavior for classes - it is easy for the Recipe class to become basic and to have successors. But we do not think about it. We have YAGNI, we were not told to do this. But we are not afraid of this.
Requirement 9Sneaky customer, having learned about our unplanned approach, decided to catch.
“I want the recipe to change.” And the stove was prepared according to any recipe compiled by the chef.We enter into polemics:
- How to change?
- We believe that the cook does not know the recipes and can experiment. Have you made recipes fixed? And he wants to add a different number of eggs, sugar, etc.
- What kind of a loaf will we get in this case? We have to get something? Bread, pies or cake? Obviously, if the recipes are different, the cook will bake something else.
- I think that the cake is different in taste, sweeter, less sweet. Also bread. It means that the recipes may differ in some limits, but we will get some product from the list.
- Ie to find out what we get, we need to look for the closest recipe to that food list in the cook recipe?
- Yes.AnalysisWe have fixed recipes. Now recipes may not be fixed. But those that we have are reference. To allow our code users to create their own recipes, it’s enough to make the constructor open. But it is also necessary to give the opportunity to ask the products. I do not want to give the ability to assign users a property or specify a specific type. Otherwise, he will be able to damage our standards. So the easiest way to give the opportunity to transfer products to the designer. It also eliminates the gap between creation and initialization and, therefore, reduces the likelihood of a bug.
Now we have two constructors:
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; }
A copy is created in the second constructor. This is because of the C # properties - to pass links by default. The user of the class will keep the link, and if he doesn’t make a copy, he can later change the ingredients of the recipe. What is not included in our plans.
Also, in this post, I try not to use less lambda and zheneriki, remaining within the framework of the standard OOP. To make it more accessible to a larger audience. The code could be written differently and simpler. But my goal is to describe the very principle of YAGNI and some ways to evaluate the code, and not to show the different possibilities of Sharp. Of course, assessment methods depend on the language and its capabilities.
The second constructor, which for users, does not set the value of the property - the name of the recipe. Since our products are transferred to the designer and cannot change, then in the same place and somehow calculate the proximity. More precisely, especially "smart" can change, but we will not suffer from paranoia. We believe that the developers are adequate and are in a position of creation, not destruction.
Need to write some kind of proximity method. The customer did not specify, so we will write the most simple, with the method of least squares. Given that each ingredient has a different "weight". For now, let's write some weights that can be customized later.
The code is approximately as follows:
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; }
And in the constructor call, respectively, the assignment of the name is added:
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(); }
It would be more reliable to calculate always on the fly. But then it would be necessary to invent a division of naming for the standard and for non-fixed recipes. That's enough for now.
As you can see, it’s not at all scary to remake the code to meet this current requirement. We didn't really break anything. And just expanded. There is no more time for revision than if we had foreseen beforehand. But to guess and not to guess is really scary. Imagine this, like a simple code, on this demand, but made earlier. This is just a monster that requires too much testing. And now it is written justified.
The code is not perfect, I already could not not start up in women and lambdas. So the code gets smaller and cleaner. I hope it doesn’t hurt the understanding of strangers with C # and lambdas readers. Of course, it can be further reduced. But I try to be understood by more people.
I have already started here on a specific algorithm, although this particular customer did not require it. Yagni is it or not? Here we understand the situation. Perhaps the customer immediately needs a visible result. More often it happens. Therefore, we need at least some algorithm. But if later we need another algorithm, it doesn’t cost anything to replace this one with another. Or even write a few and choose. Or even from users of the code to take a delegate who will do a comparison for proximity.
Understandably, now you need to transfer not the name of the recipe to the manufacturing methods, but the recipes themselves. Those. like this:
public class BakeryProduct { public BakeryProduct(Recipe recipe) { this.BakeryProductType = recipe.Name; } public RecipeName BakeryProductType { get; private set; } }
AND:
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); }
Here, refactoring is not at all overstretched.
Requirement 10The insidious customer somehow studied our code and is looking for the most painful place, for which our code is absolutely not ready. After all, we should have 9 times unreadable spaghetti.
We wrote without a plan and our code is not terribly flexible. Apparently Boris entered into a deal with him and they are looking for weaknesses.- And now I need to, that everything that is produced by the furnace, could be sold in the store and it was called a commodity. A store must place a certain amount of goods in it, each item has a price and you must be able to calculate the price of all items in the store. At the same time, workers who make bricks and rolls are not qualified and do not know how to make them. They just pour the raw material into the stove (abstract))), and get the product, i.e. goods that are brought to the store.AnalysisBefore that, our furnaces sometimes made unrelated things. Brick, for example. He has no common ancestor with the rolls. And right, how could we know what they have in common? No, of course, we also know in life about bricks and bread. But we could not believe that the customer would want to consider them later as a commodity. Then, we have three different methods that do not overlap. There is no one method that would release any thing. Should I have? Ancestor was not. Should not.Then, the class hierarchy is more evil. Like any extra line of code. The fact that at this moment we had separate methods for returning concrete classes was better, safer. Imagine that we would immediately make a furnace that does something through a single method. How did Boris. And so she would release Product. What is a Product? This is the base class. Which has a basic behavior and state. But we, for example, in the user code needed to make exactly the pie. Here, the oven, makes the product. Not a pie. Those.
this pie is really only when we get it out of the oven, it is called a product. And suddenly we needed to know how much meat there is in it. And this behavior in the heir, namely - in a pie with meat. What should we do then? Bring a link to a product to a link to its real type.And then just need to wait for trouble. When casting from the base type to the successor, the compiler cannot control the correctness of the cast. Those.
violated the severity of typing. You have to use reflection in the user code, find out what the real type is or try to bring, and then create branches, throw out exceptions, if something goes wrong, etc.Those.
premature flexibility is not only not useful, it is harmful.But now we really needed it.So, we ourselves formulate the requirements that have been formed at the moment: “Breads are made according to the recipe, there are no bricks. Bricks can be made only in a special furnace, and only bricks (not rolls) can be made in it. We need a single mechanism for loading raw materials and receiving the goods. Bricks and bricks are a commodity that has a price. ”The latter is realized simply. A common ancestor, a commodity, appeared at the rolls and bricks. public abstract class Article { public double Price { get; private set; } public Article(double price) { this.Price = price; } }
We inherit classes: 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) { } }
And refactor calls of methods of manufacturing, passing the price set by the user to the constructors.Something like this:
public static BakeryProduct BakeByOtherOven(Recipe recipe, double price) { return new BakeryProduct(recipe, price); }
This is almost half the battle. Little things left. But for the sake of reducing the post, I'll just describe what needs to be done. It is necessary to create one method which would return the goods. For consistency of parameters, it is necessary to make a class Raw materials and heirs, which will be - raw materials for rolls and raw materials for bricks. Raw materials for rolls, of course, contains a recipe. Classes are needed because just by a set of parameters (gas level, recipe, etc.) we can transmit strange parameters, which makes the program unreliable. In essence, raw material classes are ways of matching parameter packing.In a single method for the receipt of goods, we can in the simplest case use a switch and select the desired method, which is already there, for the production of what is needed, depending on the raw materials. I would do that in this case. With a small number of items in the listing, this is not very cluttering the code. With increasing number of elements, you can think of other ways. For example, about an abstract factory. With the help of overloaded methods in it, you create a raw material and a stove at the same time that can work with this raw material.As you can see, there are no difficulties to transform the architecture on the go, as requirements arrive. There is no difficulty in covering it with tests. Methods are not great. Moreover, such code is easier to cover with tests, because it is smaller. A code without prediction is always in a more or less flexible state in all directions. At the same time it is quite tough. The Boris code slipped from the distance by another third of the way. And this can be converted to infinity. To make conversions possible, you should always refactor. The principle of YAGNI only says that you need to implement only minimal functionality. But in no case does he say that if the code works, then do not touch it. The refactoring and unit tests principle YAGNI does not apply. Only then does this technology work.Naturally, the code in the post is not perfect. The goal was only to show the principle. I am sure that with many particulars in the way I did the analysis of requirements, and supporters of YAGNI will not agree. Everyone has their own personal experience. Everyone has their own methods and techniques. And it also greatly depends on the programming language, because it is a means of expressing thoughts.