Dimensional (Mixins Example)
How can we harmonize the types of a Square class and a Rectangle class so that it’s possible to
write a scale function that works on both?
As we saw in class, both Square ⊂ Rectangle and Rectangle ⊂ Square end up
violating the Liskov Substitution Principle. While there is a nice “is-a” relationship here — all
squares are rectangles! — that does not translate nicely into object-oriented inheritance
relationships. Inheritance can’t model this relationship well.
In Ruby, the door is open to try such nonsensical approaches even if we didn’t use inheritance. Duck typing means you can always try passing an object and see what happens. “I’m not here to stop you!” says Ruby. At least in Java, we could say, “Don’t ever try to substitute a square for a rectangle, or vice versa!” by not having either inherit from the other.
But the increased chaos of duck typing also opens up an interesting metaprogramming solution that is not even possible with Java.
A module is Ruby’s version of a mixin: a set of members that we can add to multiple different
classes without actually creating an inheritance relationship. A mixin is almost like copying and
pasting code between classes, but without actual duplication of code.
module Dimensional
def self.included(target_class) # When this module is included in a target class...
# We add a new metaprogramming method, dimension_attr, to
# that target class. It creates a normal attr_accessor, but
# also keeps track of all the dimension-related attrs on
# the class.
#
def target_class.dimension_attr(name)
attr_accessor name
@dimensions ||= []
@dimensions << name
end
def target_class.dimensions
@dimensions
end
end
end
# Here are two classes that use our new mixin:
class Rectangle
include Dimensional
dimension_attr :width
dimension_attr :height
end
class Square
include Dimensional
dimension_attr :size
end
p Rectangle.dimensions
p Square.dimensions
# And here is a generic scale function that multiples all the dimensions
# by some factor, no matter how many or how few there are to adjust:
def scale!(shape, factor)
shape.class.dimensions.each do |dim_name|
# If dim_name is "width", then the following would be
# equivalent to `self.width *= factor`:
shape.send(
"#{dim_name}=",
shape.send(dim_name) * factor
)
end
end
r = Rectangle.new
r.width = 10
r.height = 20
scale!(r, 3)
p r
s = Square.new
s.size = 127
scale!(s, 3)
p s
# Now we can have an arbitrary number of dimensional attributes that need to be scaled!
class Cuboid
include Dimensional
dimension_attr :width
dimension_attr :height
dimension_attr :depth
end
c = Cuboid.new
c.width = 10
c.height = 50
c.depth = 300
scale!(c, 3)
p c
# Why stop there?
class SmileyFace
include Dimensional
dimension_attr :radius
dimension_attr :eye_radius
dimension_attr :eye_separation
dimension_attr :eye_center_y
dimension_attr :mouth_width
dimension_attr :mouth_center_y
end
face = SmileyFace.new
face.radius = 10
face.eye_radius = 1
face.eye_separation = 5
face.eye_center_y = -2
face.mouth_width = 6
face.mouth_center_y = 5
scale!(face, 10)
p face
There is a relationship between SmileyFace and Square: they have a set of dimension-related
attributes that should all scale together. That’s not a relationship that we explicitly model in
Ruby’s type system, but the implicit relationship is there thanks to duck typing.
To model that as a type relationship in Java, we would have to give up on those properties being
normal getter and setters backed by instance variables, and instead use, say, a Map<DimensionKeys,
Double> to track them all.
Aside: more about Java
That isn’t strictly true: we could finagle something like the Ruby version using a Java feature called “reflection” which allows us to access parts of our class structure by name at runtime. We can’t create new structures on existing classes, as metaprogramming can, but we could manually write normal properties for the dimensions and then use reflection to scale them all together. This would be horrendously messy in practice. It’s technically possible. It is not, however, a solution anyone would be likely to reach for.
But in Ruby, they can be normal properties and part of an arbitrary-length data structure. We give up static type safety, but we buy some fairly wild metaprogramming flexibility.