Data back in Traits with empty, non-typed objects. What is this object pattern?


#1

Python scripting after having read the amazing Rust’s doc, I am keen to
remodelate OOP in my mind. And it feels easy to experiment that with python’s
fairly simple, loose and powerful object structure and introspection.

Now after a few months, I’ve got this recurring pattern occuring in my scripts.

  • trying not to use inheritance as such, but composition patterns instead…
  • trying to discriminate object’s operational interface (something about the
    Trait concept) vs. object’s data (something about the Struct)…
  • → I always end up wishing to benefit from so-called "inherited struct"
    recurring obsession

After a read to this really interesting article, I finally figured out that
the pattern I’m obsessed with is the following: in this pattern…

  • Data is supposed to be worked on. Working on data is made via an
    interface. An interface is described and defined by a Trait. In this sense,
    data specification is fundamentally bound to a trait, and not an object.
  • An object is an empty ‘thing’ we stick traits on until it has the
    desired shape.

As a consequence, I am craving to make data specification part of traits again,
but in a way that keeps:

  • the composition-over-inheritance pattern (any combination of trait can be
    stuck onto an object)
  • code reusability with Traits requirements and default implementations (where
    Runs ⇾ Walks means “Runs requires Walks” and not “Runs is/inherit-from
    Walks”)
  • polymorphism with methods overriding opportunities

Considering data access: there is no plain data since every piece of data is
bound to a specific trait. Therefore, I only allow myself to access data if I
can explicitly tell which trait it serves.

Here is how I have highjacked python object structure to this end:

import inspect # for introspection

class Aggregate(object):
    """Objects are empty things.
    Aggregate is the most basic data holder. It is *empty* until traits
    (statically) stick the data fields they need onto it.
    It is temporary used in this python hack for collecting members defined
    by the _data method of each Trait: it'll be passed during `__init__` as
    a `self` to Trait._data.
    """
    pass

class Trait(object):
    """Traits require/override each other, and define the data they need.
    Every data member will have to be explicitly prefixed by a string
    characterizing the only one trait it serves to.
    """

    _prefix = '' # no prefix by default, but that's *no* good.

    def _featured_traits(self):
        """Iterate on all "required" (python-'inherited') traits once, from the
        top down.
        """
        # skip 'object' and 'Trait' at the end
        return reversed(inspect.getmro(type(self))[:-2])

    def __init__(self):
        """Setup appropriate prefixed field names for all featured traits.
        (this could be done statically in a compiled language, right?)
        """
        for trait in self._featured_traits():
            if '_data' in trait.__dict__:
                # use a temporary Aggregate collector
                aggregate = Aggregate()
                # fill it up with defaults fields and values provided by the
                # trait:
                trait._data(aggregate)
                # then define these fields on the instance object with the
                # right prefix on
                prefix = trait._prefix + '_'
                for field_name, value in aggregate.__dict__.items():
                    setattr(self, prefix + field_name, value)
            else:
                # some traits have no particular data to define
                pass

In use: a model specification

class Moves(Trait):
    """Characterizes walking objects.
    """

    _prefix = 'move'

    def _data(data):
        """walking objects have a position
        """
        # initializing is instanciator's responsibility
        data.x = None # float
        data.y = None # float

    def shift(self, motion):
        """Elementary motion.
        """
        # access data with explicit trait prefix!
        self.move_x += motion[0]
        self.move_y += motion[1]

    def create_motion(self, delta):
        """built a motion object a coordinates shift
        """
        raise NotImplementedError(
                "This makes this trait 'Abstract'.")

    def __repr__(self):
        """visualize status
        """
        return "{}({}, {})".format(
                type(self).__name__,
                self.move_x,
                self.move_y,
                )

class Walks(Moves):
    """Characterizes regularly-moving objects.
    *Requires* trait `Moves` (and not "inheritate" from it)
    """

    _prefix = 'walk'

    def _data(data):
        """constand slow speed
        """
        data.speed = None # positive number
        data.walking = None # boolean

    def toggle_cruise(self):
        """Starts/Stops walking
        """
        # access with explicit trait prefix!
        self.walk_walking ^= True

    def update(self, dt):
        """Shift at cruise speed.
        """
        if self.walk_walking:
            delta = self.walk_speed * dt
            motion = self.create_motion(delta)
            self.shift(motion) # provided by `Moves` interface

    def create_motion(self, delta):
        """same x and y speed, say
        """
        return (delta, delta)

class Runs(Walks):
    """Faster than walkers
    """

    _prefix = 'run'

    def _data(data):
        data.coeff = None # number > 1.

    def update(self, dt):
        """override Walks.update
        """
        # all this data is statically known to be available because Runs
        # trait *requires* Walks to be implemented.
        if self.walk_walking:
            delta = self.walk_speed * dt
            # apply coeff!
            delta *= self.run_coeff # specific to this trait
            motion = self.create_motion(delta) # provided by `Walks` trait
            self.shift(motion) # provided by `Moves` indirectly required

class Flies(Moves):
    """Trait introducing a new coordinate.
    """

    _prefix = 'fly'

    def _data(data):
        data.z = None

    def elevate(self, shift):
        """motion specific to fliers.
        """
        self.fly_z += shift

    def shift(self, motion):
        """Refining Moves.shift
        """
        # resolve explicit Moves method:
        Moves.shift(self, motion)
        self.fly_z += motion[2]

    def create_motion(self, delta):
        """same x, y and half z speed, say
        """
        return (delta, delta, .5 * delta)

    def __repr__(self):
        """refine visualization
        """
        return "{}({}, {}, {})".format(
                type(self).__name__,
                self.move_x,
                self.move_y,
                self.fly_z,
                )

class Dragon(Runs, Flies):
    """`Move` Trait is "indirectly required twice" but it's not a problem. Its
    associated methods and data will just be available because *required*.
    """

    _prefix = 'dragon'

    # explicit polymorphism picking (could be resolved statically, right?)
    create_motion = Flies.create_motion

    def _data(data):
        """no specific data to handle
        """
        pass

    def update(self, dt):
        """Refine Runs.update
        """
        Runs.update(self, dt)
        self.elevate(50.) # say.

In actual use: a model instanciation


# a = Moves() # cannot: Move is abstract.

# create
a = Walks()
# initialize (could be done from a dedicated factory)
a.move_x = a.move_y = 0.
a.walk_walking = True
a.walk_speed = 1.
# use
a.update(2.)
a # Walks(2.0, 2.0)
a.walk_walking = False
a.update(2.)
a # Walks(2.0, 2.0)

# create
a = Runs()
# initialize (could be done from a dedicated factory)
a.move_x = a.move_y = 0.
a.walk_walking = False
a.walk_speed = 1.
a.run_coeff = 3.
# use
a.update(1.)
a # Runs(0.0, 0.0)
a.walk_walking = True
a.update(1.)
a # Runs(3.0, 3.0)


# create
a = Flies()
# initialize (could be done from a dedicated factory)
a.move_x = a.move_y = a.fly_z = 0.
# use
a.elevate(2.)
a # Flies(0.0, 0.0, 2.0)

# create
a = Dragon()
# initialize (could be done from a dedicated factory)
a.move_x = a.move_y = a.fly_z = 0.
a.walk_walking = True
a.walk_speed = 1.
a.run_coeff = 3.
# use
a.update(1.)
a # Dragon(3.0, 3.0, 51.5)

TL;DR What is this approach? Does it have a name? Has it been explored? Has
it already been encouraged/deprecated and why? Can Rust feature that?


#2

This sounds similar to the pattern of “mixins”, which I recall the Ruby ecosystem was crazy about back when that was a thing, and they’re occassionally seen in python. My gut reaction is that they’re still more similar to inheritance than composition, because you can’t e.g put multiple copies of the same mixin into a type.

There have been talks to find some way to put data members on traits, like this RFC, although it looks like the lang team doesn’t have the bandwidth to really work on it now.


#3

Mixins are inheritance.

irb(main):001:0> module Foo
irb(main):002:1> end
=> nil
irb(main):003:0> class Bar
irb(main):004:1>   include Foo
irb(main):005:1> end
=> Bar
irb(main):006:0> Bar.ancestors
=> [Bar, Foo, Object, Kernel, BasicObject]

Interestingly in Ruby, classes are Modules that can be allocated (they even inhert from them). Methods are held by modules.

irb(main):012:0> Class.instance_methods - Module.instance_methods
=> [:allocate, :new, :superclass]

Rust has no object model at all, so mapping these approaches to Rust needs to work with other techniques.


#4

Well. Mixins do look quite similar, thank you for this keyword!

you can’t e.g put multiple copies of the same mixin into a type.

I’m not sure what you mean. Do you mean Defining a trait requiring twice another one like Runs(Walks, Walks)? What would be the semantics of this? What would you use it for?

There have been talks to find some way to put data members on traits, like this RFC

Yeah, I’m pretty excited about that (but I can only post 2 links as new user :P). Hope they’ll find enough resource to make this eventually work.

My gut reaction is that they’re still more similar to inheritance than composition

Maybe this feeling comes from their “requirement” hierarchy that looks much like an inheritance tree. But there is a fundamental difference in my opinion:

In this pattern I’m obsessed with, there are no types, only interfaces/traits/methods. I think that the common question:

  • “What is this object?” (i.e. what is this type)
    • and B ⇾ A means “B is an A

is not the good question, because the only thing I ever ultimately want to know is:

  • “What can this object do?” (i.e. what is its interface), no matter what it “is”.
    • and B ⇾ A means “B can do what A can do

As a consequence, the resemblance between requirement tree and the inheritance tree is just their tree-looking structure, but they mean something completely different, since at the moment we’re needing objects:

function(Walks w, Runs r, Dragon d, # w can do what Walks offers, not "w is a Walks" (no types)
        {Swims, Jumps, Flies} a) {  # a can do what Swims, Jumps and Flies offer, whatever a "is"
   w.walks(); # guaranteed possible
   r.run();   # guaranteed possible
   d.dragon_data += 7. ; # guaranteed available
   a.swim(); # no matter..
   a.jump(); # .. what a "is"..
   a.fly();  # .. because "is" means nothing, do you get my point?
}

I actually wish that objects (or “Structs”) had no types at all, which is why I wrote they are ultimately empty. Only their interface matters.

Does this ring any bell? What is the use for types / what’s left for types once I’ve started thinking this way?


#5

Regarding Python, I suggest to use static typing:
http://www.mypy-lang.org/

Regarding Rust traits see also:


#6

Well, thanks. “Static duck typing” also seems sounds like a close concept to me. Maybe I am just describing “duck typing” in a way that:

  • bundles methods together into “Traits” containers (or ‘namespaces’)
  • refuses to call type of a “duck typed” object a “type”, but rather an “interface” :slight_smile: