What is super. Examples of Composition, Inheritance, and Interfaces

Image of a keyboard flying through the air

So when I was first programming, the concept of super() was a hard for me to grasp. I saw things like react components where things would break if you suddenly forgot to include it in certainly places.

Simply put super (or base, or parent depending on the language) explicitly import behavior from something else. So let’s start with a language that doesn’t have this like go.

type Cat struct {
    name string
}

func (c Cat) Name() string {
    return c.name
}

type Dog struct {
    name string
}

func (d Dog) Name() string {
    return d.name
}

So we’ve got a Dog and a Cat here, both with names. In Go, we can relate these through interfaces - things that share common behavior.

// Here's an interface that we can use to relate to everything that has a name
type Nameable interface {
    Name() string
}

func namePrinter(n Nameable) {
    fmt.Println(n.Name())
}

// Both Cat and Dog implement Nameable, so we can use them interchangeably
cat := Cat{name: "Whiskers"}
dog := Dog{name: "Fido"}

namePrinter(cat) // Whiskers
namePrinter(dog) // Fido

Now let’s see how we can use composition to create more complex structures:

type Animal struct {
    Nameable
    age int
}

func animalNamePrinter(a Animal) {
    fmt.Println(a.Name())
}

// Create animals using composition - embedding the Nameable interface
cat := Animal{
    Nameable: Cat{
        name: "Whiskers",
    },
    age: 2,
}

dog := Animal{
    Nameable: Dog{
        name: "Fido",
    },
    age: 3,
}

// Both work with our functions
namePrinter(cat) // Whiskers
animalNamePrinter(cat) // Whiskers
namePrinter(dog) // Fido
animalNamePrinter(dog) // Fido

So our interface says here are a bunch of things that have this in common. It doesn’t require any extension of the Cat or Dog structs themselves and just asserts that anything passed as Nameable will meet the requirements of the interface.

The composition method creates a struct that explicitly includes the Nameable interface. So while we have to add in the behavior ourselves, it gives us a means to glue a bunch of things together and reason about the whole.

Now let’s look at super(), and to do so we’ll switch to some python code, and afterward’s we’ll look at what’s going on.

class Animal:
    def __init__(self, name):
        self.name = name

    def get_name(self):
        return self.name

Here’s our base Animal class with a name and a method to get the name. Now we can create specific animals that inherit from this base class.

class Cat(Animal):
    def __init__(self, name):
        super().__init__(name)

class Dog(Animal):
    def __init__(self, name):
        super().__init__(name)

# Create our animals
cat = Cat("Whiskers")
dog = Dog("Fido")

print(cat.get_name()) # Whiskers
print(dog.get_name()) # Fido

So when we initialize a Cat or Dog, we call super().__init__(name). Super lets us reference the parent class’ implementation, in this case the initialization and the methods and properties that were implemented for Animal. This is really attractive, as if we had 100’s of animals, we wouldn’t have to implement get_name for each of them. This can also be chained together. So Animal -> Kingdom -> Phylum -> Class -> Order... Each class is aware of its parent in a linked-list fashion.

However, here’s the issue I’ve often run into. What if we have the proverbial horse with no name, that is some situation where we want to include some aspects of Animal, but not all of it. How much work would it take to extend our systems to accommodate this new behavior?

// First we update our definition of Animal as not all animals are namable

type Animal interface {
    // Nameable <-- This was removed
    GetAge() int
}

// Our new horse
type HorseWithNoName struct {
    age int
}
func (h HorseWithNoName) GetAge() int {
    return h.age
}

// Our implementation of Cat and Dog do not have the change
// ... Snip ...

func namePrinter(n []Nameable) {
    for _, animal := range n {
        fmt.Println(animal.Name())
    }
}

func agePrinter(a []Animal) {
    for _, animal := range a {
        fmt.Println(animal.GetAge())
    }
}

This didn’t require much change to our code, and the functions can tie directly to the aspect that pertains to their use case. All animals can still be handled by the animalAgePrinter function, while only nameable animals work with namePrinter.

Now let’s see how this same scenario plays out with Python’s inheritance model:

# We can keep our Animal, but we'd have to add a nil name or override get_name()
class HorseWithNoName(Animal):
    def __init__(self, age):
        # Problem: We still need to call super().__init__() but with what name?
        super().__init__("")  # We have to provide something, even if it's empty
        self.age = age

    def get_name(self):
        return "This horse has no name"

# This works, but it's awkward
horse = HorseWithNoName(5)
print(horse.get_name())  # "This horse has no name"

Or we have to bisect Animal into base Animals and Nameable Animals:

# Separate concerns - not all animals have names
class Animal:
    def __init__(self, age=0):
        self.age = age

    def get_age(self):
        return self.age

class NameableAnimal(Animal):
    def __init__(self, name, age=0):
        super().__init__(age)
        self.name = name

    def get_name(self):
        return self.name

class Cat(NameableAnimal):
    def __init__(self, name, age=0):
        super().__init__(name, age)
# ... snipped dog example ...

class HorseWithNoName(Animal):
    def __init__(self, age):
        super().__init__(age)

# Now we can handle both cases cleanly
def print_animal_ages(animals: List[Animal]):
    for animal in animals:
        print(f"Age: {animal.get_age()}")

def print_nameables(nameables: List[NameableAnimal]):
    for n in nameables:
        print(f"Name: {n.get_name()}")

cat = Cat("Whiskers", 2)
dog = Dog("Fido", 3)
horse = HorseWithNoName(5)

print_animal_ages([cat, dog, horse])
print_nameables([cat, dog])

The Trade-offs

The Go composition approach gives us flexibility at the cost of more verbosity. We have to explicitly implement the methods for each structure, but we can compose those structures together, or build interfaces that allow us to interact with aspects that are shared between them.

Python’s inheritance with super() provides a clean way to extend functionality, but it can become problematic when the inheritance hierarchy doesn’t perfectly match the real-world relationships between objects. The “horse with no name” scenario highlights how inheritance can force us into awkward compromises or require significant refactoring.

As I’ve written more things I’ve come to write more composition-based code. For when my future self comes back to that code the idea of locality of reference, e.g. keeping the related code together as much as possible, makes a huge effect on how long it takes me to get up-to-speed on a codebase.

References