That Documentation Thing

gmb published on
10 min, 1918 words

An image of papers on bookshelves
Photo by Maarten van den Heuvel on Unsplash

Documentation. It’s one of the most important — and most neglected — parts of a developer’s day-to-day job. How we write documentation shapes the way we think, the way we build a product, and the way that product is handed on to the next engineer who has to maintain it (who may or may not be a future version of ourselves).

As with everything, I’ve formed some opinions about it. Read on if you’re interested.

Code-level comments (or “but code should be self documenting!”)

Okay, let’s get this out of the way. There’s an argument that goes like this: “If you’re writing comments in your code, your code isn’t clear enough. You should strive to write fewer comments.” And I just want to say… “yeah, kinda…” but it’s really not that clear cut at all.

You should absolutely strive for self-commenting code. You should, without exception, work to make your code clear and concise and unambiguous as to its behaviour. You should avoid side-effects at all costs and write for re-use as much as possible. All this is true.

And yet, if you’re working on an enterprise-level application, or really anything more than a few thousand lines-of-code in size, you’re going to run into a situation where you really wish you’d written some documentation.

All too often we forget that the decisions we make as developers aren’t just about what to do in what order. There’s a reason for doing things in a particular way, and the older and more complex a system gets, the “whys” can be easy to lose sight of.

Comments are nearly always there for the next person to read the code (though that might also be the person writing the code right now). If you’re a developer coming fresh to a project, or to a specific module, then comments that explain the non-obvious “whys” of what’s going on are really helpful, can save you time (maybe you don’t spend time trying to refactor what looks like a complex blob of code because there’s a comment that says “I know this is ugly but here’s why…”), and can help preserve institutional knowledge.

Consider this example from Launchpad’s external bug tracker syncing functionality.

    def getRemoteBugBatch(self, bug_ids):
        """See `ExternalBugTracker`."""
        csv_data = re.split(r"\r?\n0", self.csv_data.strip())

        if not csv_data:
            raise UnparsableBugData("Empty CSV for %s" % self.baseurl)

        parser = MantisBugBatchParser(csv_data, self._logger)
        return parser.getBugs()

At first glance, this is a simple function. Ensure that there’s some CSV data to parse, parse it, and return the bugs from it.

But there’s that first line. that re.split(r"\r?\n0"…). Why’s that there? Without documentation, we’ve no idea. And sure, there should be unit tests to cover that (this is Launchpad, and I know who wrote this code; I don’t need to look to know that there probably are tests, and good ones). But that doesn’t tell us why the code exists, just that it exists and behaves as intended.

Now look at the code with the comment in place that I stripped out for the sake of making a point:

    def getRemoteBugBatch(self, bug_ids):
        """See `ExternalBugTracker`."""
        # XXX: Gavin Panella 2007-09-06 bug=137780:
        # You may find this zero in "\r?\n0" funny. Well I don't. This is
        # to work around the fact that Mantis' CSV export doesn't cope
        # with the fact that the bug summary can contain embedded "\r\n"
        # characters! I don't see a better way to handle this short of
        # not using the CSV module and forcing all lines to have the
        # same number as fields as the header.
        csv_data = re.split(r"\r?\n0", self.csv_data.strip())

        if not csv_data:
            raise UnparsableBugData("Empty CSV for %s" % self.baseurl)

        parser = MantisBugBatchParser(csv_data, self._logger)
        return parser.getBugs()

Now we know a couple of things: why the code is there from the point of view of working around a technical issue, and why that particular code is there, rather than something better-suited.

When to use code-level / inline comments

Whenever you need to pass on institutional knowledge to the next developer, and you wouldn’t, as a developer, want to have to hunt around for this information.

Specifically, you should at least consider adding comments in the code in the following situations:

  • You’ve written code in a way that’s more performant, but harder to read. Explain this to the reader.
  • You’re handling an edge case with your code. Explain why the edge case must be handled here, and why it must be handled like this. Reference bugs / tickets if possible.
  • There’s some other context that you need to capture at the point of writing the code which won’t necessarily be obvious to the next developer, but which will help them to not do the wrong thing when working with the code.

Basic rules for in-line comments

  • Don’t write essays. Pith is essential.
  • But: be unambiguous in what you’re trying to say, even if that means writing a longer comment.
  • Don’t explain the obvious, e.g.:
# Do the thing
do_the_thing()

Function-level documentation

Python calls them docstrings. PHP, doc-blocks. But whatever the language, there’s a way of adding function-level documentation, usually looking something like this:

def say_hello(name: str) -> None:
    """Say hello to `name`.
    
    :param name: The name of the person to whom to say 'hello'.
    """
    print(f"Hello, {name}!")

or (for TypeScript)

/**
 * Say hello to `name`.\
 *
 * @param {string} name - The name of the person to whom to say 'hello'.
 */
function sayHello(name: string): null {
    console.log(`Hello, ${name}!`)
}

or Rust

///
/// Say hello to `name`.\
/// 
/// # Arguments
/// * `name` - The name of the person to whom to say hello.
fn say_hello(name: &str) {
    println!("Hello, {}!", name);
}

Note that for all of these I’ve used an imperative style for the descriptive summary of what the function does. That’s a habit – Python PEP257 requires that the first line of a doctoring be in the imperative mood (“Do the thing” rather than “Does the thing”). This is something I’ve carried through to other languages that I work with as I find it more explicit about the behaviour of a function, and because it allows the developer to think in terms of “Now I want to Do The Thing…”

When to Use Function-level Docs

Caveat Function-level docs are no substitute for sensible, descriptive function names.

In short, as much as possible. Of course, you don’t have to add comments if your function is pretty short and has a very obvious function, e.g.:

class Person:
    """Represents a distinct human being."""
    
    def get_pet_names(self) -> List[str]:
        return [pet.name for pet in self.get_pets()]

But even then, it can be helpful to have a docstring:

class Person:
    """Represents a distinct human being."""
    
    def get_pet_names(self) -> List[str]:
        """Return a list of the Person's pet names, ordered alphabetically."""
        return sorted(pet.name for pet in self.get_pets())

Doctests

Some languages (I’m thinking specifically of Python and Rust here, but this is probably a feature available in other languages; I just don’t know) allow you to embed tests in your function comments. This is a great way to demonstrate how to use a function whilst also ensuring that your example code remains relevant and correct — because the tests will fail if you don’t keep them up-to-date.

Here’s a Python example:

def say_hello(name: str) -> None:
    """Say hello to `name`.
    
    >>> say_hello("Graham")
    "Hello, Graham!"
    """
    print(f"Hello, {name}!")

Extra-code Docs

That covers it for docs which live in the code. But what about docs that live outside of the code?

README files

The README file of any project is the place to put really important information that’s essential to getting up and running with that project as a developer — and in many cases, as a user.

Things to include in a README file:

  • A summary of what the project is for and how it serves its purpose
  • How to get started on the project as a developer, specifically:
    • How to install the dependencies
    • How to run the software locally, including any services that it may require
    • How to run the test suite
    • Details of any environment variables which may need to be set in order for things to work.
  • How to get started with the software as a user
  • Any gotchas for either developers or users, and how to work around them.
  • If writing primarily for a developer audience (e.g. for an company-internal or open-source library), give details of the coding standards, testing process, and code review process for the project. Link to external documents as needs be.

In-repo Docs

These can be markdown files, or restructured text, or whatever textual format you like.

I like to use in-repo docs to explain at length the specifications, design, and thought processes behind the software. Depending on your code hosting set-up, you can include diagrams in the files themselves, using something like Mermaid, or you can put PNGs in there exported from your preferred diagram software.

Ultimately, these documents are for your fellow developers. They’re the specifications, the history, and the thought processes that led to the building of the software you’re using.

Again, the handy thing about docs in the repo is that you can write executable examples in them and use your language’s doctest (if your language supports such) functionality to run them. Though you shouldn’t rely on these to do serious testing of your code — that’s what unit tests are for, and doctests are slow — but it’s a great way to ensure that the examples actually work.

Project Wikis / Confluence

Confluence — or, as I’ve been known to refer to it, “where information goes to die.”

I honestly don’t much like project wikis, and I really don’t like Confluence, because it’s slow and clunky. However, it can’t be overstated that they’re a great way for non-technical folk to build documentation for a product.

The hard part, of course, is knowing where the dividing line is between docs that should sit in Confluence and docs which should sit in the repo, next to the code.

Usually, my feeling is that you can do this one of two ways:

  1. Everything in Confluence, or
  2. Developer docs in the repo, stakeholder docs in Confluence.

It’s a tricky line to walk, and you may find yourself moving docs from the repo into Confluence (though almost never the other way around). But ultimately the second approach allows you to keep implementation documentation separate from the business-requirement docs which underpin it. Keeping it close to the code means that someone who wants to work on a bug fix doesn’t need to be able to access Confluence in order to find the developer docs. They just need to check out the repo.

If you’re auto-generating API docs based on your docstrings and doc-blocks, you should ensure that these docs don’t get committed to the codebase. Instead, you can have your CI pipeline generate the documentation and put it somewhere that’s easily reachable for developers. If a developer wants to generate the docs locally, they should be able to do so. By preventing the generated docs from being committed, you ensure that you don’t end up with unnecessarily noisy commits every time the API docs change.