skip to content
blog.metters.dev

Using ASCII art to document data structures in code

/ 4 min read

Sometimes there is a requirement to add a complex data structure to software, for example directed acyclic graphs (DAGs). Let’s just assume there is no other way, and it has to be done.

I had to implement a DAG-like data structure for a feature request and there was no suitable thirdparty library. So all functions, like checks for circular dependencies and adding a new relationship had to implemented by myself. Extensive testing and documentation was essential in order to let my future self and others understand what was going on. I even felt the urge to add (Javadoc-like1) comments to many tests and several fixture methods.

Useless Javadoc?

Usually I do not rely much on Javadoc or comments to describe what a single method does, especially if they are internal. In the following simple example, the method’s signature and its name almost tell everything that needs to be known. It makes the Javadoc seem redundantβ€”the only additional information there, is how negative input values are handled.

/**
* Returns true, if the input is divisible by 3.
*
* @param input must be a non-negative integer.
* @return true, if input is divisible by 3.
* @throws IllegalArgumentException, if input is negative.
*/
boolean isDivisibleByThree (int input) { ... }

Useful Javadoc?

I changed my mind about this, when I had to deal with complex data structures: adding comments at the methods’ signatures was a major relief in documenting what was going on. The challenge was to explain the relationships between entities without writing a novel. That’s where ASCII art (I was using asciiflow as tool) came to play. I mostly used it to annotate tests and fixture methods, especially for those related to the method that was checking for circular dependencies.

It is not easy to keep track of the test setup. Take the following example, and try to understand the test setup without Javadoc. Those three comments are supposed to help a bit, because it is difficult to understand random code examples without any context.


Click to expand the test without Javadoc

@Test
fun `that detects circular dependency with three participating entities`() {
val joe = anEntity("joe")
val sarah = anEntityWithHeir("sarah", joe)
val bob = anEntityWithHeir("bob", sarah)
mockEntityRepository(joe, bob, sarah) // (1)
mockFindDirectParent(joe, sarah) // (2)
mockFindDirectParent(sarah, bob) // (3)
assertThrows<SomeCircularDependencyException> {
sut.checkForCircularDependencies(joe.id, bob.id)
}
}
// (1) Helper method to set up the mocked repository to return the correct entity by id
// (2) Set up relationship: joe inherits from sarah
// (3) Set up relationship: sarah inherits from bob


Click to expand the test annotated with Javadoc

/**
* A new relationship is tried to be established between joe and bob.
* β”Œβ”€β”€β”€β”€β”€β”
* β”‚ joe │◄───┐
* β””β”€β”€β”€β”¬β”€β”˜ β”‚
* β”‚ β”Œβ”€β”€β”΄β”€β”€β”
* *new*β”‚ β”‚sarahβ”‚
* β”‚ β””β”€β”€β–²β”€β”€β”˜
* β”Œβ”€β”€β”€β–Όβ”€β” β”‚
* β”‚ bob β”œβ”€β”€β”€β”€β”˜
* β””β”€β”€β”€β”€β”€β”˜
*/
@Test
fun `that detects circular dependency with three participating entities`() {
val joe = anEntity("joe")
val sarah = anEntityWithHeir("sarah", joe)
val bob = anEntityWithHeir("bob", sarah)
mockEntityRepository(joe, bob, sarah) // (1)
mockFindDirectParent(joe, sarah) // (2)
mockFindDirectParent(sarah, bob) // (3)
assertThrows<SomeCircularDependencyException> {
sut.checkForCircularDependencies(joe.id, bob.id)
}
}
// (1) Helper method to set up the mocked repository to return the correct entity by id
// (2) Set up relationship: joe inherits from sarah
// (3) Set up relationship: sarah inherits from bob


Now imagine, having to understand more complex setups, containing several additional entities and having even more relationships with each other! ASCII art illustrations allow the reader to comprehend the setup without much effort.

Lessons learnt

Implementing this, taught me a few things. Most are not directly related to this blog post, I am going to document those here nevertheless:

  1. Annotate methods with illustrations made from ASCII art, instead of adding plain text comments only
    • This reduces the cognitive load
    • I used asciiflow, which did not cause me any problems
  2. Extensive testing is your best friend
    • Incrementally increase the complexity of the tests cases
    • Avoid deleting simpler tests, even if those are covered by more complex examples. Usually, I would remove test cases that are already covered as part of the refactoring, but in the given scenario the simpler tests proved useful to help readers understand the code better.
    • Consider helper methods or convenience methods (fixtures) to make the test setup more readable. It also reduces the risk of a faulty test setup (see below).
  3. Double-check the test setup
    • Watch out, to not prepare faulty data structures that differ from what is documented. Debugging such a case is tricky.

Footnotes

  1. I did not use Javadoc or multiline comments (opening with /** or /*) for the ASCII art illustrations, because IntelliJ IDEA’s code formatter destroyed the comment’s structure. Commenting out each line separately with // prevented this. The examples above are Javadoc nonetheless. ↩