Search code examples
pythonpython-3.xmonkeypatchingvisitor-patternalgebraic-data-types

Interpreting ASTs in Python 3.6: isinstance vs. monkey-patching vs. visit_NodeType vs. macros?


Suppose that I want to write a tiny interpreter that can evaluate expressions with binary operation Plus, unary operation Negate and integer constants.

I'm currently interested only in the interpretation of the AST, so let's skip tokenization and parsing for simplicity.

In Haskell, there is a more or less canonical way to do it:

data Ast = Plus Ast Ast | Negate Ast | IntConst Int

ev :: Ast -> Int
ev (Plus a b) = (ev a) + (ev b)
ev (Negate x) = - (ev x)
ev (IntConst i) = i

main = print $ show $ ev $ (Plus (IntConst 50) (Negate $ IntConst 8))

Now, Python 3.6 does not seem to have algebraic datatypes. My problem is that there seem to be many possible work-arounds. The most obvious one is using isinstance:

class Plus:
  def __init__(self, first, second):
    self.first = first
    self.second = second

class Negate:
  def __init__(self, first):
    self.first = first

class IntConst:
  def __init__(self, value):
    self.value = value

def ev(ast):
  if isinstance(ast, Plus):
    return ev(ast.first) + ev(ast.second)
  elif isinstance(ast, Negate):
    return - ev(ast.first)
  elif isinstance(ast, IntConst):
    return ast.value

print(ev(Plus(IntConst(50), Negate(IntConst(8)))))

This works and prints 42 as expected, but looks somewhat noisy.

Here are a few more options, but each has some drawbacks:

  1. Use monkey-patching: e.g. this example defines bunch of ???_execute methods in the interpreter, and then attaches them to the classes that represent elements of the AST. This looks very scary to me (I don't want to know what happens if I try to execute two separate ASTs with two different interpreters in parallel: everything would break, right?).
  2. Define a generic NodeVisitor that has a visit_???-method for every type of AST-node and then does some dispatching gluing the right method name from strings and the name of the class of the instance passed to the visit-method. This seems somewhat more robust, but I don't like that the method names are rebuild permanently: the interpreter should concentrate on the AST, not on generating its own source code (method names).
  3. Use some additional macro-gizmos that apparently can generate case-classes. I currently don't want to use any third-party tools, I want to have a tiny little script that is as independent from everything else as possible.

I didn't find the answer to this related question satisfactory, because the only answer just links to some external tool, which is moreover no longer maintained.

So, is there some standard way in Python 3.6.x to define interpreters for ASTs that don't have the above mentioned drawbacks? Or should I just stick to isinstance? Or implement good old Java-style Visitor (not sure whether it's considered pythonic)?


EDIT

Using functools proposed by @juanpa.arrivillaga, I came up with the following:

  1. Use collections.namedtuple and functools.singledispatch:

    from collections import namedtuple
    from functools import singledispatch
    
    Plus = namedtuple('Plus', ['left', 'right'])
    Negate = namedtuple('Negate', ['arg'])
    IntConst = namedtuple('IntConst', ['value'])
    
    @singledispatch 
    def ev(x): raise NotImplementedError("not exhaustive: %r" % (type(x)))
    ev.register(Plus, lambda p: ev(p.left) + ev(p.right))
    ev.register(Negate, lambda n: -ev(n.arg))
    ev.register(IntConst, lambda c: c.value)
    
    print(ev(Plus(IntConst(50), Negate(IntConst(8)))))
    

    However, it does not seem to work if ev is a method, because it cannot dispatch on the self-argument (see this related question), so I can get only a function ev, but no instance that represents the interperter.


Solution

  • If cleaner code is what you are looking for, I think the functools.singledispatch decorator would work in this case:

    import functools
    
    class Plus:
      def __init__(self, first, second):
        self.first = first
        self.second = second
    
    class Negate:
      def __init__(self, first):
        self.first = first
    
    class IntConst:
      def __init__(self, value):
        self.value = value
    
    @functools.singledispatch
    def ev(ast):
        raise NotImplementedError('Unsupported type')
    
    @ev.register(Plus)
    def _(ast):
        return ev(ast.first) + ev(ast.second)
    
    @ev.register(Negate)
    def _(ast):
        return -ev(ast.first)
    
    @ev.register(IntConst)
    def _(ast):
        return ast.value
    
    print(ev(Plus(IntConst(50), Negate(IntConst(8)))))