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?