stlab / adobe-contract-checks

Contract checking facilities for C++
Boost Software License 1.0
6 stars 0 forks source link

ci CodeQL


Adobe Contract Checking

This library is for checking that software contracts are upheld. In C++ these checks can be especially important for safety because failure to satisfy contracts typically leads to undefined behavior, which can manifest as crashes, data loss, and security vulnerabilities.

This library provides macros for checking preconditions and invariants, and can be viewed as an improvement upon the standard assert macro. However, the discipline and rationales documented here are just as important to the library's value as are its mechanics.

Design by Contract

Design by Contract is the industry-standard way to describe the requirements and guarantees of any software component. It is based on three concepts:

Postconditions should be checked by unit tests (rationale).

Documenting Contracts

The minimal documentation required for any component is its contract. Writing this documentation need not be a burden; usually, a short sentence fragment is sufficient (examples).

Documentation is the primary vehicle for expressing contracts

Rationale 1. Some contracts cannot be checked at runtime. For example, there's no way to check these preconditions: ```c++ /// Returns the frobnication of `p` and `f`. /// /// - Precondition: `p` points to an initialized object. /// - Precondition: `f(x)` returns a value from `0` through `1.0` /// for any `x`.” auto frob(X* p, float (*f)(int)) -> bool; ``` 2. Reasoning locally about code depends on being able to understand the contract of each component the code uses without looking at the component's implementation. From a client's point of view, contract checks are hidden inside the implementation.

Additionally describing contracts in code and checking them at runtime can be a powerful way to catch bugs early and prevent their damaging effects. That's the role of this library.

How Reported Errors Fit In

The condition that causes a function to throw an exception or otherwise report an error to its caller should not be treated as a precondition. Instead, make the error reporting behavior part of the function's specification: document the behavior and test it to make sure that it works. Also, do not describe the error report as part of the postcondition. Reporting an error to the caller exempts a function from fulfilling postconditions and can be thought of as an unavoidable failure to fulfill postconditions.

For example:

/// Returns a pointer to a colorful widget.
///
/// Throws std::bad_alloc if memory is exhausted.
std::unique_ptr<Widget> build_widget();

The first line of documentation above describes the function's postcondition. The second line describes its error reporting, separately from the postcondition. You can eliminate the need to document exceptions by setting a project-wide policy that, unless a function is noexcept, it can throw anything. You can eliminate the need to document returned errors by encoding the ability to return an error in the function's signature. Documenting which exceptions can be thrown or errors reported is not crucial, but documenting the fact that an error can occur is.

Unless otherwise specified in the function's documentation, a reported error means all objects the function would otherwise modify are invalid for all uses, except as the target of destruction or assignment. Discarding this invalid data is the obligation of code that stops error propagation to callers.

Because this invalid data must be discarded, code that reports or propagates errors need not uphold class invariants; the only properties of the class that must be maintained are destructibility and assignability. Note that this policy is less strict than what is implied by the basic exception safety guarantee, and supersedes the stricter policy with the endorsement of its inventor.

Upholding the obligation to discard invalid mutated data is reasonably easy if types under mutation have value semantics, because data forms a tree and the invalidated data is always uniquely a part of the objects being mutated at the level of the error-handling code. Otherwise it may be necessary to discard other parts of the object graph.

The usual, and most useful, way of specifying that data under mutation is not invalidated is by making the strong guarantee that there are no effects in case of an error (where possible without loss of efficiency). When the callee can make that promise, it exempts the caller from discarding invalid data.

Basic C++ Usage

This is a header-only library. To use it from C++, simply put the include directory of this repository in your #include path, and #include <adobe/contract_checks.hpp>.

#include <adobe/contract_checks.hpp>

The two macros used to check contracts,ADOBE_PRECONDITION and ADOBE_INVARIANT, each take one required argument and one optional argument:

The precise effects of a contract violation depend on this library's configuration.

For example,

#include <adobe/contract_checks.hpp>
#include <climits>

/// A half-open range of integers.
/// - Invariant: start() <= end().
class int_range {
  /// The lower bound; if `*this` is non-empty, its
  /// least contained value.
  int _start;
  /// The upper bound; if `*this` is non-empty, one
  /// greater than its greatest contained value.
  int _end;

  /// Returns `true` if and only if the invariants are intact.
  bool is_valid() const { return start() <= end(); }
public:
  /// An instance with the given bounds.
  /// - Precondition: `end >= start`.
  int_range(int start, int end) : _start(start), _end(end) {
    ADOBE_PRECONDITION(end >= start, "invalid range bounds.");
    ADOBE_INVARIANT(is_valid());
  }

  /// Returns the lower bound: if `*this` is non-empty, its
  /// least contained value.
  int start() const { return _start; }

  /// Returns the upper bound; if `*this` is non-empty, one
  /// greater than its greatest contained value.
  int end() const { return _end; }

  /// Increases the upper bound by 1.
  /// - Precondition: `end() < INT_MAX`.
  void grow_rightward() {
    ADOBE_PRECONDITION(end() < INT_MAX);
    int old_end = end();
    _end += 1;
    ADOBE_INVARIANT(is_valid());
  }

  /// more methods...
};

Configuration

The behavior of this library is configured by one preprocessor symbol, ADOBE_CONTRACT_VIOLATION. It can be defined to one of three strings, or be left undefined, in which case it defaults to verbose.

Except in unsafe mode, a failed check ultimately calls std::terminate() because:

  1. Continuing in the face of a detected bug is considered harmful, and
  2. Unlike other methods of halting, std::terminate() allows for emergency shutdown measures.

This library can only have one configuration in an executable, so the privilege of choosing a configuration for all components always belongs to the top-level project in a build.

To avoid ODR violations, any binary libraries (not built from source) that use this library must use the same version of this library, and if they use this library in public header files, must have been built with the same value of ADOBE_CONTRACT_VIOLATION.

Basic CMake Usage

To use this library from CMake and uphold the discipline described above, you might put something like this in your project's top level CMakeLists.txt:

include(FetchContent)
if(PROJECT_IS_TOP_LEVEL)
  FetchContent_Declare(
    adobe-contract-checks
    GIT_REPOSITORY https://github.com/stlab/adobe-contract-checks.git
    GIT_TAG        <this library's release version>
  )
  FetchContent_MakeAvailable(adobe-contract-checks)

  # Set adobe-contract-checks configuration default based on build
  # type.
  if(CMAKE_BUILD_TYPE EQUALS "Debug")
    set(default_ADOBE_CONTRACT_VIOLATION "verbose")
  else()
    set(default_ADOBE_CONTRACT_VIOLATION "lightweight")
  endif()
  # declare the option so user can configure on CMake command-line or
  # in CMakeCache.txt.
  option(ADOBE_CONTRACT_VIOLATION
    "Behavior when a contract violation is detected"
    "${default_ADOBE_CONTRACT_VIOLATION}")
endif()
find_package(adobe-contract-checks)

# Configure usage of this library by all targets the same way.
# (repeated in each CMakeLists.txt that adds C++ targets).
if(DEFINED ADOBE_CONTRACT_VIOLATION)
  add_compile_definitions(
    "ADOBE_CONTRACT_VIOLATION=${ADOBE_CONTRACT_VIOLATION}")
endif()

# --- your project's targets -----

add_library(my-library my-library.cpp)
target_link_libraries(my-library PRIVATE adobe-contract-checks)

add_executable(my-executable my-executable.cpp)
target_link_libraries(my-executable PRIVATE adobe-contract-checks)

Recommendations

Rationales

Why This Library Provides No Postcondition Check

Checking postconditions is practically the entire raison d'être of unit tests, and many good frameworks for unit testing exist. Adding a postcondition check to this library would just create confusion about where postcondition checks belong and about the purpose of unit testing. Also, postcondition checks for most mutating functions need to make an initial copy of everything being mutated, which can be prohibitively expensive even for debug builds.

Why This Library Does Not Throw Exceptions

In the original Eiffel programming language implementation of Design by Contract, a contract violation would cause an exception to be thrown. On the surface, that might seem at first like a good response to bug detection, but there are several problems:

If your function really needs to throw an exception, that should be a documented part of its contract, so that response can be tested for and callers can respond appropriately. See How Reported Errors Fit In for more information.

About Defensive Programming

According to Wikipedia:

Defensive programming is a form of defensive design intended to develop programs that are capable of detecting potential security abnormalities and make predetermined responses.[1] It ensures the continuing function of a piece of software under unforeseen circumstances.

In principle, defensive programming as defined above is a good idea. As defensive programming is commonly practiced, though, “unforeseen circumstances” usually mean the discovery of a bug at runtime. Trying to keep running in the presence of bugs is in general a losing battle:

Note that in an unsafe language like C++, a seemingly recoverable condition like the discovery of a negative index can easily be the result of undefined behavior that also scrambled memory or causes “impossible” execution.

Development

The usual procedures for development with CMake apply. One typical set of commands might be:

cmake -DBUILD_TESTING -Wno-dev -S . -B ../build -GNinja        # configure
cmake --build ../build                         # build/rebuild after changes
ctest --output-on-failure --test-dir ../build  # test