About the design. Part 2. Practical examples

    As we discussed last time , the design thing is not simple; constantly have to keep in mind a bunch of all sorts of options and try to find a compromise among the many different requirements that tear apart your elegant solution. On the one hand, I want the solution to be easy to maintain, well extensible, with high performance, and it should be clear not only to its author, but to at least one other person; I want the solution to eat little memory and not violate any of the 100 500 principles of OOP, and, most importantly, we want to finish it at least this year, although the manager constantly insists that it should have been ready a month ago.

    Many of these criteria are not very compatible with each other, so sooner or later we come to the conclusion that a good design is like that , which tries to squeeze between dozens of conflicting requirements, and find a reasonable compromise that best meets the most significant requirements, not forgetting that the weight of these requirements may also change over time.


    Due to its ambiguity, one can find many examples where two different teams prefer different criteria; one group may consider security solutions to be a more important criterion, while another group may prefer effectiveness. Such ambiguity leads to the fact that different projects use a whole zoo of technologies, and all of them can be very different from each other in terms of implementation (let alone go far, even within the .Net Framework it is quite easy to find different solutions to the same problems).

    But enough to philosophize, let's look at a few more or less specific examples.

    Efficiency and maintainability

    I think that one of the most common compromises that most developers face is the compromise between the effectiveness (productivity) of the solution and its maintainability.

    Let's look at such an example.

    internal class SomeType
    {
        private readonly int _i = 42;
        private readonly string _s = "42";
        private readonly double _d;
        public SomeType()
        { }
        public SomeType(double d)
        {
            _d = d;
        }
    }
    


      This is a very typical example where some class contains default values ​​that are initialized when a field is declared. However, this example leads to some code swelling, as the C # language compiler converts it to something like this:

    public SomeType()
    {
        _i = 42;
        _s = "42";
        // System.Object::ctor();
    }
    public SomeType(double d)
    {
        _i = 42;
        _s = "42";
        // System.Object::ctor();
        _d = d;
    }
    


    Thus, all fields initialized during the declaration get their values ​​even before the base class constructor is called , thanks to this we can access them even from virtual methods called from the base class constructor, and also that all readonly fields will be guaranteed to be initialized ( but in any case, do not use this trick!). But we get this behavior due to the "swelling" of the code, which is duplicated in each constructor.

    Jeffrey Richter in his wonderful book “CLR via C #” gives the following advice: since using field initialization during the declaration can lead to code swelling, you should consider allocating a separate constructor that does all the basic initialization and explicitly call it from other constructors.

    Obviously, here we are faced with the classic compromise of readability and maintainability versus effectiveness. In general, Richter’s advice is well-founded, you just don’t need to follow it blindly. In solving this dilemma, we must clearly understand whether it is worth reducing readability (after all, you have to look for the right constructor every time to look at the default values) and maintainability (what if someone adds a new constructor and forgets to call the default constructor), the increase in productivity that we get? In most cases, the answer will be: “No, it’s not worth it!”, But if this class is a library class or it is simply instantiated millions of times, then my answer will not be so clear.

    NOTE
    You should not think that I challenge the opinion of Jeffrey Richter, you just need to clearly understand that Richter is “blinded from a different dough” than most of us; he is used to solving lower-level problems, where every millisecond counts, but this is far from being so important for most application developers.

    Safety and efficacy

    Another very common compromise in the design of some solutions is the choice between a structure and a class (between a significant type and a reference type). On the one hand, structures up to a certain size (of the order of 24 bytes on the x86 platform) can significantly improve performance due to the lack of memory allocation in the managed heap. But on the other hand, in the case of mutable significant types, we can get a number of very non-trivial problems, since the behavior can be far from what many developers suggest.

    NOTE
    Many people consider mutable significant types to be the greatest evil of our time. If you don’t understand what it’s about or simply do not agree with this opinion, then you should look at the article entitled “On the dangers of mutable significant types”maybe after that, your opinion will change;)

    Let's look at a more specific example. When implementing the enumerator of a collection, its author must decide on how to implement this enumerator: in the form of a class or in the form of a structure. In the first case, we get a much safer solution (after all, the enumerator is a "mutable" type), and in the second case, it is more effective.

    So, for example, the enumerator of the List < T > class is a structure, which means that in the following code fragment you will get behavior that will be unexpected for most of your colleagues:

    var x = new { Items = new List { 1, 2, 3 }.GetEnumerator() };
    while (x.Items.MoveNext())
    {
        Console.WriteLine(x.Items.Current);
    }
    


    Most developers who see this behavior are quite reasonable outraged by the stupidity of Redmond comrades, who clearly decided to mock their poor programmer brother. However, everything is not so simple.

    Sooner or later, a moment arises in the life of any collection when someone wants to look at its internal contents. In some cases (for example, in the case of arrays and lists) an indexer can be used for these purposes, but in most cases the collection is enumerated using the foreach loop(directly or indirectly). For most of us, one additional allocation of memory on the heap for each cycle seems to be a trifle, but the .NET environment is quite universal, and loops are one of the most common constructions of modern programming languages. And if all this will happen not on the four core processor, but on the mobile device, then such a decision by BCL developers will no longer seem so crazy.

    The choice between class and structure (especially if the structure is mutable) is a very serious decision, making which the designer must have an accurate understanding of what he will get in one case and what he will lose in another.

    Simplicity vs Versatility

    When it comes to a higher-level design, such a choice problem often arises: how much should our solution be universal, or is it enough to limit ourselves to a specific task, and only then move on to a generalized solution?

    Many programmers and architects intuitively believe that universality is the best way to deal with changing requirements. If today the customer needs only a toothpick, then let's immediately make a Swiss knife with the function of a toothpick, so, just in case, and suddenly the requirements change!

    In fact, neither we nor our customer need a universal solution in itself; all we need isprovide our solution with a certain flexibility, which will allow us to adapt it after changing existing requirements . At the same time, by and large, nobody cares how this flexibility will be ensured: due to simplicity or universality; whether you need to change the configuration file or if you need to add or change a couple of classes - this is not so important. The only important thing is how much effort the team will have to spend on a new opportunity and what consequences this decision will have (will the whole system fall apart after such a change).

    When it comes to such a choice, and we decide whether to make a “Swiss knife” from the very beginning or not, then I am inclined to some compromise solution. As I wrote in the reuse note, the most effective solution to this problem is a simple solution from the very beginning, which is generalized to one of the subsequent iterations, when necessary.

    The use of ingenious architectural constructions is still the same premature optimization as the unreasonable use of ingenious language constructions. For the most part, universality implies additional complexity, and if your expansion points are not directed where the wind of change will blow, then you will get just an overly complex and useless solution.

    When designing classes and methods, I use the following rule: any module, class or method should “expose” the minimum amount of information. This means that all classes and methods by default should be with the smallest possible scope: classes - internal (internal), methods - private (private). It sounds like a statement by a famous captain, but very often we put out “well, here is another method, it will not get any worse”. The initial solution should be as simple as possible; the less dependencies our customers have on our implementation, the easier it is for these customers to live and the easier it is for us to change our classes. Remember that encapsulation is not only hiding implementation details by a class or module, it is also protecting customers from unnecessary details.

    Libraries and Usability

    There is a certain type of tasks, the solution of which I would not entrust to any person. No, the point is not that I would not entrust this task to anyone other than myself, there are simply certain tasks that are poorly solved by one person, regardless of his level. Many tasks are much better solved jointly, but there is one type of task for which "another opinion" is simply necessary - we are talking about the development of libraries and frameworks.

    If you look through the wonderful book “Framework Design Guidelines”, then from the first pages it will become clear that the priorities of the library developer are very much shifted compared to the developer of application applications. If the application developer has the main criterion for simplicity, the convenience of maintaining the code and reducing the time-to-market, then the library developer has to think not so much about himself as about his main client: the library user.

    The library developer can score on all the principles of OOP if they contradict the main principle of the library - simplicity and intuitiveness of use. The library can be very difficult to maintain, since every solution added to it can never (or almost never) be changed.

    If in designing an application we can afford to make a mistake and change a dozen even open interfaces, then everything becomes much more complicated when your class has a couple of dozen external users. Martin Fowler has a wonderful article entitled Published vs Public Interfaces , in which he gives a clear distinction between the two. The cost of changing any “published” interface increases dramatically, which means that a mistake made during the development of the first versions of the library can and will haunt its developer for many years (here is a great example, recently described by Eric Lippert “Foolish Consistency is Foolish”) For this reason, Microsoft is in no hurry to publicize hundreds, if not thousands, of very useful classes from the .NET Framework, since each new public class significantly increases the cost of maintenance.

    The solutions to all of the tradeoffs described above are dramatically different when we move from application applications to libraries. Microoptimization, extensibility, dirty hacks, the problem of “breaking changes”, consistency (even to the detriment of many other important factors), all this is constantly found in libraries. That is why, when it comes to most of the tips regarding software development, you need to clearly understand that this most likely relates to the development of application applications, and not specialized libraries.

    Conclusion

    Most of the trade-offs we face may be divided into several categories. First, you need to be clearly aware of whether it is a framework (or a widely used reusable library) or an application. Here you need to understand that these two worlds are quite different and very cool shift priorities when choosing between two compromise solutions.

    Another very important criterion when choosing one or another solution can be an understanding of long term and short term benefits. One solution may be good for today's task, but it will certainly add a number of problems in the future. Do not forget the "technical duty", and that such metaphors can convince not only colleagues, but also the customer of the importance of “long-term prospects” when making this or that decision.

    And finally, do not forget that programming is an applied discipline, not an end in itself, therefore experience, pragmatism and common sense are three very useful tools in solving most problems.

    Also popular now: