Software Engineering · Documentation

The Contract Elements a Function Reference Entry Has to State

A signature fixes argument types and little else. Preconditions, ownership, failure modes, and concurrency guarantees are separate obligations that the reference entry has to record explicitly.

A function signature is a compressed statement of what a callee needs and what it gives back, and it is the only part of the contract a compiler checks. Everything else exists as an obligation on the caller or a promise from the implementer: which arguments may be null, whether the returned pointer must be freed, whether the call can block, whether two threads may enter it at the same time. When those terms are not written down they are transmitted by folklore or reconstructed by reading the body, and both channels fail silently.

POSIX strdup is the standard illustration. The declaration is one line:

char *strdup(const char *s);

Nothing in that line says the result is a fresh allocation of strlen(s) + 1 bytes that the caller must pass to free, or that failure is reported as a null return with errno set to ENOMEM. A caller who assumes the argument is echoed back owns a double free; a caller who assumes the result may be modified in place writes into whatever the pointer aliases. The type char * is compatible with both readings.

A signature is the smallest part of a contract

A type is a predicate over values. It says which bit patterns are admissible and, in a language with a rich type system, which operations are legal on them. It rarely says which of the admissible values are meaningful. An int parameter called timeout_ms admits -1, and the interesting question is whether -1 means “wait forever”, “return immediately”, or “undefined behaviour”. A size_t admits non-negative values up to SIZE_MAX and says nothing about a maximum buffer size the implementation will actually accept.

Three categories of obligation therefore escape the type:

  • Domain restrictions. Ranges, encodings, aliasing rules, and ordering constraints between arguments. A function that takes a pointer and a length must state whether the pointer may be null when the length is zero, because the answer differs between implementations and callers depend on it.
  • Effects. Allocation, I/O, lock acquisition, mutation of arguments passed by reference, changes to global state.
  • Guarantees. What the caller may rely on afterwards: ownership of returned memory, validity period of returned pointers into internal buffers, exception safety, post-conditions on out-parameters after a failure.

The reference entry is where these become discoverable without reading the implementation. The reason is economic rather than aesthetic: a caller who must read the body to learn the ownership rule will read a version of the body, and the rule they extract may be an implementation detail that changes.

Preconditions the type system cannot carry

Preconditions are the cheapest thing to state and the most expensive to omit. They split into constraints on individual arguments and constraints on the relationship between arguments. The first kind is easy to enumerate: a count must be at most the capacity of the buffer, a string must be NUL-terminated, an index must be less than the length. The second kind is where entries go quiet: memcpy requires that the source and destination ranges do not overlap, and that requirement is invisible in the signature of two void * arguments and a size_t. The consequence of violating it is not an error return but undefined behaviour, which is why the sentence is worth its space.

Preconditions come with a second half that is usually omitted: what happens when they are violated. “Undefined behaviour” is a legitimate answer, but it is a different contract from “returns EINVAL” and from “asserts in debug builds and does nothing in release builds”. A caller deciding whether to validate its own inputs needs to know which of the three applies. Documentation that states a precondition without stating its enforcement is half a contract.

There is a design consequence here, and it runs the other way from documentation. Every precondition that a function checks at run time costs a branch on every call, and every precondition it does not check is a documented obligation. Moving a check from the body into the type system, a newtype, a range type, a non-null pointer, is how a codebase reduces the surface the prose has to cover. The prose entry then states what remains, and the entry shrinks as the types get stronger.

Ownership, lifetime, and the return value

The return value carries at least three separate facts: what it means, how long it stays valid, and who disposes of it. strdup answers all three in one sentence in a good manual page, and each answer is a distinct obligation. Meaning includes sentinel conventions: null for failure versus empty for “no result” versus a distinguished error value such as (size_t)-1. Lifetime distinguishes a value the caller owns from a pointer into a buffer that the next call may overwrite, a category that includes strerror, getenv in some implementations, and most functions that return const char * into static storage.

Disposal is where the most consequential errors live. A function that returns allocated memory imposes a matching deallocator, and if the allocation came from a private pool the correct deallocator is not free. An entry that says “caller must free” without saying with which function is incomplete in exactly the case where the distinction matters.

The returned error object deserves the same treatment. In languages with exceptions, the declaration often omits which exceptions propagate, and a caller cannot write a handler for a type the entry never mentions. Java’s checked exceptions are the design that forces this into the signature, at the cost of every intermediate function declaring or catching; unchecked exceptions move the obligation back into prose. Neither arrangement removes the need for the entry to say which failures are possible.

Failure has to be enumerated, not implied

“It returns an error on failure” is not a specification. The useful form is a finite list of failure conditions, each with the condition that triggers it and the signal the caller receives. POSIX manual pages do this with an ERRORS section that maps each errno value to a cause, and the mapping is part of the interface: a caller that retries on EINTR and aborts on EINVAL is relying on the distinction.

An enumeration buys three things that a general statement does not. It makes retry policy decidable, since transient and permanent conditions are named separately. It makes error handling testable, since a test can provoke each named condition. And it makes the set of conditions reviewable, since adding a new failure mode to an implementation is visibly a change to the documented contract rather than an invisible one.

The enumeration also has to say what state the callee leaves behind on failure. Partial writes, partially initialised structures, and locks still held are all plausible and none is deducible from the return value. The classic case is an out-parameter that is untouched on failure in one release and zeroed on failure in the next; callers that read it unconditionally break when that changes. Stating the post-failure state is what lets a caller decide whether cleanup is necessary.

Side effects, thread safety, and cost as contractual terms

Some functions are called for their effects and return nothing interesting. For those, the effects are the contract: which file descriptors are consumed, whether a temporary file is unlinked, whether a signal handler is installed, whether the call is async-signal-safe. The last property is a hard constraint that cannot be inferred from a signature and is not uniform across a library; a function that allocates is not async-signal-safe because the allocator may take a lock.

Thread safety is a property of the pair (function, shared state), and entries that answer it with a single word mislead. The useful statement distinguishes four cases: safe to call concurrently on distinct objects, safe to call concurrently on the same object, safe only with external locking, and reentrant with respect to signal handlers. A function documented as “not thread-safe” may still be perfectly safe to call from many threads as long as no two calls share an argument, and that distinction decides whether a caller needs a lock at all.

Cost belongs in the same list when callers make design decisions from it. Asymptotic complexity is the honest coarse statement: strdup is O(n) in the length of the input and performs one allocation of n+1 bytes. Allocation behaviour is separately relevant, because a function that allocates on every call cannot be used in a tight loop or in an interrupt context regardless of its complexity class. Entries that state complexity but not allocation, or the reverse, give the reader half of what they need to predict the behaviour of a system built from these calls.

Where the entry lives, and what that placement costs

Two placements dominate. The comment adjacent to the definition, extracted into browsable form by a doc generator, or a separate page maintained by hand. Each has a structural advantage and a structural failure.

The adjacent comment wins on freshness, because the diff that changes the code sits next to the diff that changes the description, and a reviewer sees both. PEP 257 fixes conventions for Python docstrings; rustdoc and Doxygen parse comment blocks in their respective languages and produce cross-linked HTML. The failure mode is scope: a comment attached to one function is poor at describing a protocol that spans five functions, and generators that render one entry per symbol actively discourage the overview page. There is also a formatting cost, since the comment must parse as documentation markup inside a language comment, and malformed markup degrades silently rather than failing a build.

The separate page wins on structure and loses on freshness. Nothing in a hand-maintained page changes when a signature changes, unless the page is generated from the signature, and the drift is invisible until a reader follows stale instructions. What an API doc generator can recover from source is the mechanism that decides which of the two placements can be automated. The consistent arrangement is a generated reference whose entries are complete at the level of individual symbols, plus hand-written pages for the relationships between them, with the boundary stated once so readers know which document is authoritative for what.

The elements that expire

A contract is indexed by version, because behaviour changes between releases and readers arrive with a version in hand. Three annotations carry most of the load: the version in which a symbol appeared, the version in which it was deprecated, and the replacement to use. Javadoc’s @since and @deprecated are the long-standing form; rustdoc’s stability attributes serve the same purpose; most generators will render a deprecation notice prominently if the source carries one.

Deprecation entries need a fourth fact that is usually missing: what happens at removal, and on what schedule. “Deprecated” alone tells a caller to stop but not whether the current release still works, whether the behaviour is frozen or merely discouraged, or whether the next minor version removes it. The schedule matters more than the adjective, because it determines whether the migration is urgent.

The elements above are the ones a reader can act on. An entry that states them can be used without reading the implementation, which is the only test of completeness that matters: the reader should be able to predict the behaviour of a call in a case the entry’s author never considered, and to know when the entry does not cover that case. Examples are the other half of that predictability, and executing them as tests is what keeps the illustration from diverging from the behaviour it illustrates.

References

  1. PEP 257: Docstring Conventions — Python Software Foundation
  2. POSIX.1-2017: strdup(3) — The Open Group
  3. The rustdoc Book — Rust project
  4. Doxygen Manual: Documenting the code — Doxygen project