CoSAppLogo CoSApp examples

Visitors

General concept

Visitors are special objects meant to analyze, or “visit” other objects, referred to as components. The main idea is that a visitor usually responds differently when confronted to different types of components.

A real-life analogy would be a veterinarian performing health checks on animals. The actual care provided will obviously differ for cats, rabbits and snakes.

A list of animals to inspect are submitted to the veterinarian. The purpose of the visitor pattern is to avoid writting a set of instructions of the kind: “if it’s a cat, do this; if it’s a rabbit, do that; etc.

Here is how it goes, roughly: * Each animal (a.k.a. component) has a method accept(visitor), specifying which method the visitor should use on them. It is typically something like:

class Cat(Animal):
    def accept(self, visitor):
        visitor.visit_cat(self)

class Rabbit(Animal):
    def accept(self, visitor):
        visitor.visit_rabbit(self)

# And so on
  • Any visitor knowing what to do with cats, rabbits or snakes can now be sent to visit a group of animals. Thus, the base class for veterinarians will be something like:

class Veterinarian:
    def visit_cat(self, cat):
        pass

    def visit_rabbit(self, rabbit):
        pass

    def visit_snake(self, snake):
        pass

Implentations of Veterinarian will specify actions to be performed for all three kinds of supported animals.

class DentalChecker(Veterinarian):
    def visit_cat(self, cat):
        # check canines and back teeth

    def visit_rabbit(self, rabbit):
        # check front teeth

    def visit_snake(self, snake):
        # check fangs and venom ducts

class LegChecker(Veterinarian):
    def visit_cat(self, cat):
        # check retractable claws

    def visit_rabbit(self, rabbit):
        # check back leg tonicity

    def visit_snake(self, snake):
        pass  # nothing to do!

Thus, different kinds of visitors can be sent to different groups of animals, without any type check, and any change in the animal classes.

def send_vet(vet: Veterinarian, animals: List[Animal]):
    """Have `vet` visit `animals`"""
    for animal in animals:
        animal.accept(vet)

kitty = Cat()
simba = Cat()
roger = Rabbit()
monty = Snake()
conda = Snake()

legcheck = LegChecker()
dental = DentalChecker()

send_vet(dental, [simba, monty, roger])
send_vet(legcheck, [kitty, roger, simba, conda])

Implementation in CoSApp

A basic visitor pattern has been implemented in CoSApp, distinguishing systems, ports and drivers. The base interface is similar to that of Veterinarian, with empty implementations.

[1]:
from cosapp.patterns.visitor import Visitor

help(Visitor)
Help on class Visitor in module cosapp.patterns.visitor:

class Visitor(builtins.object)
 |  Base class for visitors
 |
 |  Methods defined here:
 |
 |  visit_driver(self, driver) -> None
 |
 |  visit_port(self, port) -> None
 |
 |  visit_system(self, system) -> None
 |
 |  ----------------------------------------------------------------------
 |  Data descriptors defined here:
 |
 |  __dict__
 |      dictionary for instance variables
 |
 |  __weakref__
 |      list of weak references to the object

A visitor can be something like:

[2]:
from cosapp.patterns.visitor import Visitor, send as send_visitor

class DataCollector(Visitor):

    def __init__(self):
        self.data = {
            'systems': dict(),
            'ports': dict(),
            'drivers': None,
        }

    def visit_system(self, system) -> None:
        key = system.full_name()
        self.data['systems'][key] = system.size
        send_visitor(self, system.inputs.values())

    def visit_port(self, port) -> None:
        key = type(port).__name__
        self.data['ports'].setdefault(key, 0)
        self.data['ports'][key] += 1

Specialize further a visitor

The basic interface of Visitor does not make any distinction between systems themselves. This arguably defeats the primary purpose of the visitor pattern, if the component collection only contains system.

Worry not! Here is how you can introduce more granularity in class selection.

[3]:
import numpy
from cosapp.base import Port, System
from cosapp.patterns.visitor import Visitor

class AbPort(Port):
    def setup(self):
        self.add_variable('a', 1.2)
        self.add_variable('b', 0.0)

class XyPort(Port):
    def setup(self):
        self.add_variable('x', numpy.zeros(3))
        self.add_variable('y', 1.0)

class Foo(System):
    def setup(self):
        self.add_input(AbPort, 'ab_in')
        self.add_output(XyPort, 'xy_out')

    def compute(self):
        ab = self.ab_in
        self.xy_out.x[:] = ab.a
        self.xy_out.y = 2 * ab.a - ab.b**2

    def accept(self, visitor: Visitor):
        try:
            visitor.visit_foo(self)
        except AttributeError:
            # Visitor does not have `visit_foo`
            # Fall back to base class implementation
            super().accept(visitor)

class Bar(System):
    def setup(self):
        self.add_input(XyPort, 'xy_in')
        self.add_output(AbPort, 'ab_out')
        self.add_output(XyPort, 'xy_out')

    def compute(self):
        # irrelevant, here
        pass

    def accept(self, visitor: Visitor):
        try:
            visitor.visit_bar(self)
        except AttributeError:
            super().accept(visitor)

class Bogus(System):
    def setup(self):
        self.add_inward('u', 1.0)
        self.add_outward('v', 0.0)
        # whatever

[4]:
from cosapp.patterns.visitor import Visitor

class FooBarVisitor(Visitor):

    def __init__(self):
        self.data = dict()

    def visit_system(self, system) -> None:
        # Default behaviour for `System`
        self.data.setdefault('System', [])
        self.data['System'].append(type(system).__name__)

    def visit_foo(self, foo) -> None:
        # Collect names of `Foo` instances
        self.data.setdefault('Foo', [])
        self.data['Foo'].append(foo.name)

    def visit_bar(self, foo) -> None:
        # Count `Bar` instances
        self.data.setdefault('Bar', 0)
        self.data['Bar'] += 1

[5]:
from cosapp.patterns.visitor import send as send_visitor

foo = Foo('foo')
# Primary sub-systems
foo.add_child(Bogus('p'))
foo.add_child(Bar('q'))

# Secondarty sub-systems
foo.p.add_child(System('empty'))
foo.p.add_child(Bar('bar'))

foo.q.add_child(Foo('subfoo'))

visitor1 = DataCollector()
visitor2 = FooBarVisitor()

send_visitor(visitor1, foo.tree())
send_visitor(visitor2, foo.tree())

print("visitor1:", visitor1.data, sep="\n")
print("visitor2:", visitor2.data, sep="\n")

visitor1:
{'systems': {'foo.p.empty': 1, 'foo.p.bar': 1, 'foo.p': 3, 'foo.q.subfoo': 1, 'foo.q': 2, 'foo': 6}, 'ports': {'ExtensiblePort': 6, 'ModeVarPort': 6, 'XyPort': 2, 'AbPort': 2}, 'drivers': None}
visitor2:
{'System': ['System', 'Bogus'], 'Bar': 2, 'Foo': ['subfoo', 'foo']}

Notes

  • Notice that the fallback case in Foo.accept and Bar.accept invoke super().accept(visitor), rather than visitor.visit_system(self). Even though the two calls are equivalent, the former is more robust to changes, and semantically more correct.

  • Data collection through a system tree without type specialization can usually be achieved more simply with a plain function using iterator system.tree() (see tips & tricks). Use of the visitor pattern is interesting for more complex cases.

  • The per-class specialization of method accept can also be implemented for ports and drivers.