Flutter Design Patterns: 21 — Visitor
An overview of the Visitor design pattern and its implementation in Dart and Flutter
In the last article, I have analysed a behavioural design pattern which enables loose coupling between the sender of a request and its receiver — Chain of Responsibility. In this article, I would like to analyse and implement another behavioural design pattern that lets you separate algorithms from the objects on which they operate — it is Visitor.
Table of Contents
- What is the Visitor design pattern?
- Other articles in this series
- Your contribution
What is the Visitor design pattern?
Visitor belongs to the category of behavioural design patterns. The intention of this design pattern is described in the GoF book:
Represent an operation to be performed on the elements of an object structure. Visitor lets you define a new operation without changing the classes of the elements on which it operates.
Let’s say we have a complex object structure, maybe it is a tree or collection, that consists of several different class components. Now, we want to add some kind of new functionality to these components without changing the classes themselves — is that even possible?
The key idea here is to define a double-dispatch operation (in the Visitor design pattern’s context, the operation is called accept) for each specific complex object class — I know, you could think that I lied to you about adding new operations without changing the existing code, but wait, there is a good reason for that! When clients traverse the object structure, the accept method is called on each element to delegate the request to the specific visitor object, which is passed to the method as a parameter. Then, the specific method of the visitor object is called (the request is delegated), hence performing the actual request. That’s the main idea of a double-dispatch operation — the client sends the request to the component, while component delegates the request to the specific visitor’s method. It means, that it is enough to implement a single method to the component class and then you can define a new operation over an object structure simply by adding a new visitor. And this time, you could implement as many different visitor classes as you want without changing the existing code! How cool is that?
Also, the Visitor design pattern allows gathering related operations into a single class without spreading the implementation details across the whole object structure. That helps when you want to accumulate state while traversing an object structure — there is no need to pass the state to operations that perform the accumulation since the state is stored in the visitor object itself and is accessible by all the specific visitor methods.
At first, all the visitor, accept, double-dispatch terms could look confusing — don’t worry, it gets much clearer when you see the Visitor design pattern in action. Let’s move to the analysis and implementation parts to understand and learn the details about this pattern and how to implement it!
The general structure of the Visitor design pattern looks like this:
- Visitor — declares a visit operation for each concrete element class in the object structure. If the programming language supports function overloading, visit operations could have the same name (Dart does not support that at the moment), but the type of their parameters must be different. Usually, the operation’s name and signature is different and identifies the class (concrete element) that sends the visit request to the visitor;
- Concrete visitors — implements each operation declared by Visitor;
- Element — declares an accept method that takes Visitor as an argument;
- Concrete elements — implements the acceptance method. The implementation should rely on redirecting the request to the proper visitor’s method corresponding to the current element class;
- Client — usually contains a collection or a complex object structure, initialises the concrete visitor object and then traverses the object structure by visiting each element with the visitor.
The primary purpose of the Visitor design pattern is to separate algorithms from the objects on which they operate, hence cleaning up the business logic. This way, the classes of your app could focus on their main job while auxiliary behaviours are extracted into a set of visitor classes. Also, visitors allows keeping the related operations together by defining them in one class.
Furthermore, the Visitor design pattern should be used when you want to execute an operation on all elements of a complex object structure and you do not want to change the interface(s) of concrete classes. Different visiting method implementations are executed on different classes which accept the visitors, hence the specific implementation details could be changed or new specific visitor implementations could be added without interfering the existing code base of the object structure and its components.
Finally, there is one important thing to note: the Visitor design pattern only makes sense for object structures that rarely change (as always, take it with a grain of salt). If you just want to change or add new implementations of visitor — that’s fine. However, changing the object structure classes requires redefining the interface to all visitors which could become cumbersome and violates the Open-Closed (the letter O in SOLID principles). A simple solution to this problem is just defining the operations in those classes without extracting them to a visitor.
For the implementation part, we will use the Visitor design pattern on the already implemented complex object structure which was introduced with the Composite design pattern. In my opinion, it would be a great example of how different design patterns could complement each other and how to reuse/extend the already existing codebase.
Our complex object structure is a file system which consists of directories and files of various types (audio, video, text, etc.). Let’s say that this kind of structure is already implemented using the Composite design pattern. Now, we want to add a possibility to export such file structure in two different formats: human-readable (just provide each file in a single, formatted list) and XML.
The first possible approach to implement this feature is to define the export method for each specific file type. In this case, this is wrong for several reasons:
- For each specific export option, we would need to implement a separate export method in each specific file class. Also, by adding a new export option in the future, we would need to add some extra code to each file class once again.
- It’s a violation of Single-responsibility principle. The export functionality is just an auxiliary operation applied on top of the file structure, hence each specific file should not care and store the implementation details inside the class itself.
As you could guess, these problems could be easily resolved by applying the Visitor design pattern and defining each specific export option in a separate visitor class which takes care of all the specific implementation details for all the file types in a single place. Let’s check the class diagram first and then implement the pattern!
The class diagram below shows the implementation of the Visitor design pattern:
IFile defines a common interface for both File and Directory classes:
- getSize() — returns the size of the file;
- render() — renders the component’s UI;
- accept() — delegates request to a visitor.
File class implements the getSize() and render() methods, additionally contains title, fileExtension, size and icon properties.
AudioFile, ImageFile, TextFile and VideoFile are concrete file classes implementing the accept() method from IFile interface and containing some additional information about the specific file.
Directory implements the same required methods as File, but it also contains title, level, isInitiallyExpanded properties and files list, containing the IFile objects. It also defines the addFile() method, which allows adding IFile objects to the directory (files list). Similarly as in specific file classes, accept() method is implemented here as well.
IVisitor defines a common interface for the specific visitor classes:
- getTitle() — returns the title of the visitor that is used in the UI;
- visitDirectory() — defines a visiting method for the Directory class;
- visitAudioFile() — defines a visiting method for the AudioFile class;
- visitImageFile() — defines a visiting method for the ImageFile class;
- visitTextFile() — defines a visiting method for the TextFile class;
- visitVideoFile() — defines a visiting method for the VideoFile class.
HumanReadableVisitor and XmlVisitor are concrete visitor classes that implement visit methods for each specific file type.
VisitorExample contains a list of visitors implementing the IVisitor interface and the composite file structure. The selected visitor is used to format the visible files structure as text and provide it to the UI.
An interface which defines methods to be implemented by specific files and directories. The interface also defines an accept() method which is used for the Visitor design pattern implementation. Dart language does not support the interface as a class type, so we define an interface by creating an abstract class and providing a method header (name, return type, parameters) without the default implementation.
A concrete implementation of the IFile interface. In File class, getSize() method simply returns the file size, render() — returns file’s UI widget which is used in the example screen.
Concrete file classes
All of the specific file type classes implement the accept() method that delegates request to the specific visitor’s method.
- AudioFile — a specific file class representing the audio file type that contains an additional albumTitle property.
- ImageFile — a specific file class representing the image file type that contains an additional resolution property.
- TextFile — a specific file class representing the text file type that contains an additional content property.
- VideoFile — a specific file class representing the video file type that contains an additional directedBy property.
A concrete implementation of the IFile interface. Similarly as in File class, render() returns directory’s UI widget which is used in the example screen. However, in this class getSize() method calculates the directory size by calling the getSize() method for each item in the files list and adding up the results. Also, the class implements the accept() method that delegates request to the specific visitor’s method for the directory.
Defines an extension method indentAndAddNewLine that adds nTab tabs at the beginning and a new line symbol at the end of a String.
An interface which defines methods to be implemented by all specific visitors.
- HumanReadableVisitor — implements the specific visitor that provides file information of each file type in a human-readable format.
- XmlVisitor — implements the specific visitor that provides file information of each file type in XML format.
First of all, a markdown file is prepared and provided as a pattern’s description:
The VisitorExample widget contains the buildMediaDirectory() method which builds the file structure for the example. Also, it contains a list of different visitors and provides it to the FilesVisitorSelection widget where the index of a specific visitor is selected by triggering the setSelectedVisitorIndex() method.
When exporting files’ information and providing it in the modal via the showFilesDialog() method, the example widget does not care about the concrete selected visitor as long as it implements the IVisitor interface. The selected visitor is just applied to the whole file structure by passing it as a parameter to the accept() method, hence retrieving the formatted files’ structure as text and providing it to the opened FilesDialog modal.
As you can see in the example, by selecting the specific visitor (export as text or XML option), the file structure is exported in the corresponding text format and provided to the user.
All of the code changes for the Visitor design pattern and its example implementation could be found here.
Other articles in this series
👏 Press the clap button below to show your support and motivate me to write better!
💬 Leave a response to this article by providing your insights, comments or wishes for the series.
📢 Share this article with your friends, colleagues in social media.
➕ Follow me on Medium.
⭐ Star the Github repository.