Problem Link to heading

You’ve written an object or class. It’s somewhat complex and you want to iterate ways to better abstract your code but are unsure how to start. There exists documentation about what a good abstraction looks like, but no concrete iterative steps.

Being deep inside your own code can make you blind to obvious alternatives.

Solution Link to heading

Create a bipartite graph of instance methods and member variables. Draw edges between methods and the variables they use. Then move isolated components into separate classes.

Concrete, Simple Example Link to heading

A spaceship Link to heading

Assume you’re designing a video game with a spaceship. spaceships are circles that have some amount of health they can also heal (if they aren’t dead already). spaceships also have some location and you need to know if two spaceships intersect.

type Ship struct {
    Health int
    LocX   float64
    LocY   float64
    Size   float64
}

func (s *Ship) Heal(amnt int) {
    if s.Health >= 0 {
        s.Health += amnt
    }
}

func (s *Ship) Harm(amnt int) {
    if s.Health >= 0 {
        s.Health -= amnt
    }
}

func (s *Ship) Intersects(other Ship) bool {
    dist := math.Sqrt(math.Pow(s.LocX-other.LocX, 2) + math.Pow(s.LocY-other.LocY, 2))
    return dist < s.Size+other.Size
}

Let’s created a bipartite graph of instance methods and member variables to see if we can simplify this abstraction.

Bipartite graph of methods and members

Bipartite graph of methods and members

Notice how we have two disconnected subgraphs. One connecting health with heal or harm, and another connecting intersects with location and size. This is a sign we are probably dealing with two separate game concepts: one about killing actors of the game and another about objects with physical locations. We can use composition to break our ship object into two concepts.

type Ship2 struct {
    GameKillable
    PhysicalObject
}

type PhysicalObject struct {
    LocX float64
    LocY float64
    Size float64
}

func (s *PhysicalObject) Intersects(other PhysicalObject) bool {
    dist := math.Sqrt(math.Pow(s.LocX-other.LocX, 2) + math.Pow(s.LocY-other.LocY, 2))
    return dist < s.Size+other.Size
}

type GameKillable struct {
    Health int
}

func (s *GameKillable) Heal(amnt int) {
    if s.Health >= 0 {
        s.Health += amnt
    }
}

func (s *GameKillable) Harm(amnt int) {
    if s.Health >= 0 {
        s.Health -= amnt
    }
}

Notice how Ship2 still does the same amount of things, but we can now reason about those things as smaller units. As a bonus, we have discovered the concepts of physical objects and killable objects. You could imagine making smaller interfaces or packages around these atomic units, spreading this abstraction to other places of your game.

Two brains with one object each

Complex objects are difficult to reason about. Smaller objects are easier to fit into our mental space.

Conclusion Link to heading

It’s not necessary to literally draw out a bipartite graph to simplify software, but knowing the process is something you can often do in your head to create smaller abstractions.

When hunting for simplified abstractions, consider the interactions between member variables and member functions