Some thoughts on the Visitor pattern

    Recently, I often have to use the well-known “Visitor” pattern (aka Visitor, hereinafter referred to as visitor). Previously, I neglected it, considered it a crutch, an extra complication of the code. In this article, I will share my thoughts about what is good, in my opinion, what is bad in this pattern, what tasks it helps to solve and how to simplify its use. The code will be in C #. If interested - please, under cat.



    What it is ?


    To begin with, let's recall a little what kind of pattern this is and what it is used for. Those familiar with it can be viewed diagonally. Let's say we have some library with a hierarchy of geometric shapes.



    Now we need to learn how to calculate their area. How? No problem. Add the method to IFigure and implement it. Everything is great, except that now our library depends on a library of algorithms.

    Then we needed to display a description of each figure in the console. And then draw the shapes. By adding the appropriate methods, we inflate our library, simultaneously severely violating SRP and OCP.

    What to do? Of course, in separate libraries to create classes that solve the problems we need. How do they know which particular figure was given to them? Cast!

    public void Draw(IFigure figure)
            {
                if (figure is Rectangle)
                {
                    ///////
                    return;
                }
                if (figure is Triangle)
                {
                    ///////
                    return;
                }
                if (figure is Triangle)
                {
                    ///////
                    return;
                }
            }

    Did you see a mistake? And I noticed her only in runtime. Downcasting is a universally recognized bad taste, a way to violate LSP, etc., etc. ... There are languages ​​whose type system solves our problem “out of the box” (see multimethods), but C # does not apply to them.

    This is where the Visitor aka Visitor comes to the rescue. The bottom line is this: there is a visitor class that contains methods for working with each of the specific implementations of our abstraction. And each specific implementation contains a method that does one single thing - it passes itself on to the corresponding method of the visitor.



    A bit confusing, isn't it? In general, one of the main drawbacks of the visitor is that not everyone immediately enters it (I judge by myself). Those. its use slightly increases the threshold of complexity of your system.

    What happened? As you can see, all the logic is outside our geometric shapes, and in visitors. No type casting in runtime - the choice of method for each figure is determined during compilation. We were able to get around the problems that we encountered a little earlier. It would seem that everything is wonderful? Of course not. There are drawbacks, but about them - at the very end.

    Cooking options


    What type of value should the Visit and AcceptVisitor methods return? In the classic version, they are void. What to do in case of calculating the area? You can create a property in the visitor and assign a value to it, and after calling Visit read it. But it is much more convenient that the AcceptVisitor method immediately returns a result. In our case, the result type is double, but it is obvious that this is not always the case. We will make the visitor and AcceptVisitor method generics.

    public interface IFigure
        {
            T AcceptVisitor(IFiguresVisitor visitor);
        }
    public interface IFiguresVisitor
        {
            T Visit(Rectangle rectangle);
            T Visit(Triangle triangle);
            T Visit(Circle circle);
        }
    

    Such an interface can be used in all cases. For asynchronous operations, the result type is Task. If nothing needs to be returned, then the return type can be a dummy type, known in functional languages ​​as Unit. In C #, it is also defined in some libraries, for example, in Reactive Extensions.

    There are situations when, depending on the type of object, we need to perform some trivial action, and only in one place of the program. For example, in practice, it is unlikely that we will need to display the name of the figure anywhere, except in the test example. Or in some unit test, you need to determine that the shape is a circle or a rectangle. Well, for each such primitive case to create a new entity - a specialized visitor? You can do differently:

    public class FiguresVisitor : IFiguresVisitor
        {
            private readonly Func _ifCircle;
            private readonly Func _ifRectangle;
            private readonly Func _ifTriangle;
            public FiguresVisitor(Func ifRectangle, Func ifTrian-gle, Func ifCircle)
            {
                _ifRectangle = ifRectangle;
                _ifTriangle = ifTriangle;
                _ifCircle = ifCircle;
            }
            public T Visit(Rectangle rectangle) => _ifRectangle(rectangle);
            public T Visit(Triangle triangle) => _ifTriangle(triangle);
            public T Visit(Circle circle) => _ifCircle(circle);
        } 
    

    public double CalcArea(IFigure figure)
            {
                var visitor = new FiguresVisitor(
                    r => r.Height * r.Width,
                    t =>
                    {
                        var p = (t.A + t.B + t.C) / 2;
                        return Math.Sqrt(p * (p - t.A) * (p - t.B) * (p - t.C));
                    },
                    c => Math.PI * c.Radius * c.Radius);
                return figure.AcceptVisitor(visitor);
            }
    

    As you can see, it turned out something resembling pattern matching. Not the one that was added to C # 7 and which, in fact, is just powdered downcasting, but typed and controlled by the compiler.

    But what if we have a dozen figures, and we only need to perform something special for one or two, and for the rest - some kind of “default” action? Copying a dozen identical expressions into the constructor is lazy and ugly. What about this syntax?

    string description = figure
                    .IfRectangle(r => $"Rectangle with area={r.Height * r.Width}")
                    .Else(() => "Not rectangle");
    

    bool isCircle = figure
                    .IfCircle(_=>true)
                    .Else(() => false);
    

    In the last example, we got a real analogue of the “is” operator! The implementation of this factory for our set of figures, like all other sources, is on a github . The question begs - what, for each case, write this boilerplate? Yes. Or you can, armed with T4 and Roslyn, write a code generator. Frankly, I planned to do this by the time the article was published, but I didn’t have enough time.

    disadvantages


    Of course, the visitor has enough drawbacks and limitations in use. Take at least the AcceptVisitor method from IFifgure. What does it have to do with geometry? Yes, no. So again we have a violation of SRP.

    Next, take a look at the diagram again.



    We see a closed system where everyone knows about everyone. Each type of hierarchy knows about the visitor - the visitor knows about all types - therefore, each type transitively knows about all the others! Adding a new type (shape in our example) actually affects everyone. And this is again a direct violation of the previously mentioned Open Close Principle. If we are able to change the code, then this even has a significant plus - if we add a new figure, the compiler will force us to add the appropriate method to the visitor’s interface and its implementation, we will not forget anything. But what if we are only library users, not authors, and cannot change the hierarchy? No way. We can’t expand the structure of another with a visitor. It is not for nothing that in all definitions of a pattern they write that it is used in the presence of an established hierarchy. Thus,

    Total


    The Visitor pattern is very convenient when we are able to make changes to its code. It allows you to get away from downcasting, its "non-extensibility" allows the compiler to ensure that you add all the handlers for all the freshly added types.

    If we are writing a library that can be expanded by adding new types, then the visitor cannot be used. And then what? Yes, the same downcasting wrapped up in pattern matching in C # 7. Or come up with something more interesting. If it works out, I’ll try to write about it as well.

    And, of course, I will be glad to read opinions and ideas in comments.
    Thanks for attention!

    Also popular now: