Table of Contents:

The purpose of this guideline is to provide advice how to handle evolution of your API/ABI in order to minimize breakage and keep your library binary compatible as much as possible.

Other higher level Apertis documents are available regarding the API stability:

  • Supported API aims to explain the relevant issues around API (Application Programming Interface) and ABI (Application Binary Interface) stability. It introduces as well, the strategy used by several big projects to maintain a certain stability without sacrificing the evolution of their components.
  • While API Stability is a brief description at a higher level of the API stability and versioning importance.

Why API/ABI breakage should be minimized?

API (for Application Programming Interface) is the interfaces exposed by a library. This is the interface as given in the source, so human-readable and high-level. ABI (for Application Binary Interface) is similar to API, but is obtained after compilation. This is the interface accessed in machine-code and low-level, thus it is not human-readable. In other words, the ABI defines how binaries access to the library. It defines how data is stored and the calling conventions of the library symbols.

Changes in the API/ABI of a library can result in a source incompatibility or in a binary incompatibility.

A library version is said binary compatible when a software built and dynamically linked against a previous version of this library is still running correctly with a new version of the library without having to rebuild it. If the software has incorrect behavior or crashes with a new version of the library, then this new version is binary incompatible.

Whereas a library version is said source compatible when a software needs to be rebuilt against the new version of the library without having to modify its source code. If some errors happen at compilation time, then the software needs to be modified/adapted for the new version of the library. In this case, the library version is source incompatible.

Having a library binary compatible is important to avoid rebuilding all software depending on it. Thus it is benefit in term of gain of time, gain of resource, less issues, etc.

Some other modifications like interprocess communication (IPC) changes are technically ABI breaks, but they are not addressed in this document.

Impact of API/ABI breakage in a system like Apertis

In a large and complex system like Apertis, introducing an API/ABI breakage should be done with consideration. Many components have interdependencies, thus breaking one could lead to break the whole chain. In order to minimize that, a workflow must be followed. We can find two distinct cases, either we have an ABI change without API change or we have both an ABI and an API change.

In the first case, a new version introduces an ABI change (soname bump) without API change. Then, all packages depending of this library need to be rebuilt without any packaging change. Please note, not only the packages with a direct dependency, but the whole dependency chain need to be rebuilt. To get an idea, some examples of these transitions can be found on Debian’s transition tracker. For Apertis, the rebuild is done by OBS.

In Debian and Apertis systems, the name of development package (-dev) of a library doesn’t contain (in general) the soversion. They have the following form libraryname-dev in opposite of binary packages. Not having the soversion in its name allow a rebuild of all reverse dependencies without having to change the Build-Depends field of the packaging. See the Debian policy for development packages.

Whereas in the latter case, when a new version introduce an ABI and API change, then all dependencies have to be adapted to the new API. This requires work for developers of dependencies and for package maintainers as their packages will have to be updated.

How to increment ABI version?

API/ABI version and library version are not necessarily the same. This document aims to guide developers to increment their API/ABI version. How the library version is incremented is rather a software management decision. As a random example, we can take the dav1d library. The library version 1.0.0 has for corresponding API/ABI version 6.6.0.

Let’s take as an example a dummy foo library in version 2.4.1.

The soname (library file name) of this library will be libfoo.so.2. With foo the name of your library and 2 the soversion of your library. In other word, the soversion is defined by the major version. Both soname and soversion are important because they are used as a runtime dependency for a software. The library with the correct ABI version will be loaded.

The library is installed with its full version string in its file name (i.e. libfoo.so.2.4.1). Several symlinks are also installed with only parts of the version string (i.e. libfoo.so.2.4 and libfoo.so.2). Binaries depend on libfoo.so.2 which is symlinked to a compatible library (here libfoo.so.2.4.1). This mechanism allows to update libraries to newer versions without breaking binaries depending on them. Let’s take a myfoo binary linked to libfoo.so.2, by updating libfoo.so.2.4.1 to libfoo.so.2.5.3, the symlink libfoo.so.2 will be updated to target the new libfoo.so.2.5.3, then the dependency of myfoo is available. Thus, it is possible to use at runtime a library with a higher minor version (libfoo.so.2.5) than the library used to link the software at built time (libfoo.so.2.4) as long as the major (ABI) version is the same.

The schema versioning is defined in the Semantic Versioning Specification. SemVer is a common way to handle versioning. Basically, a version number contains three components MAJOR.MINOR.PATCH:

  • MAJOR version when you make incompatible API/ABI changes.
  • MINOR version when you add functionality in a backwards compatible manner.
  • PATCH version when you make backwards compatible bug fixes.

Not following a proper version update mechanism, is considered as a bug. Not correctly reflecting API/ABI changes in the version can result in unexpected breakages for reverse dependencies (components depending on the library introducing an API/ABI change).

Allowed changes in a PATCH incrementation

The incrementation of PATCH component is used when changes only fix bugs and do not modify the existing API/ABI of the library. Changes introduced with a PATCH incrementation are backwards compatible.

Below is a basic fix bug example that does not break the API:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#include <iostream>
#include <ctime>

using namespace std;

void CurrentTime() {
   time_t rawtime = time(0);

   char *dt = ctime(&rawtime);
-   cout << "Current time in London: " << dt << endl;
+   cout << "Current local time: " << dt << endl;
}

int main()
{
   CurrentTime();
}

Releasing a new version with this kind of fixes is clearly a PATCH incrementation because it includes only small bug fix without adding new functionalities nor changing existing entities in a way that breaks API/ABI. In case of our dummy foo library, we will increase the PATCH component, thus giving us the new version 2.4.2 compared to the previous 2.4.1 version.

Allowed changes in a MINOR incrementation

The MINOR incrementation is used when new functionalities are added to the library in a backwards compatible manner. An incrementation of the MINOR component should not break the API.

Below is a basic example adding a function without breaking the existing API:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>
#include <ctime>

using namespace std;

void CurrentTime() {
   time_t rawtime = time(0);

   char *dt = ctime(&rawtime);
   cout << "Current local time: " << dt << endl;
}

+void CurrentGMTTime() {
+   time_t rawtime = time(0);
+
+   tm *g = gmtime(&rawtime);
+   char *dt = asctime(g);
+   cout << "Current GMT time: "<< dt << endl;
+}

int main()
{
   CurrentTime();
}

This change introduces a new function CurrentGMTTime() without touching previous functionalities. A release of this version will allow to provide a new binary compatible library version with new features. If a downstream component needs this new function, then it can add a minimal dependency version on the MINOR adding the feature. Let’s take again our foo library, we will then increment the MINOR component and reinitialize the PATCH, thus we will release the version 2.5.0.

A non-exhaustive list of allowed changes in a minor version incrementation:

  • Classes
    • Add new classes
    • Newly export classes
    • Change friend declarations
    • Add new enum to a class
  • Functions
    • Add new functions
  • Deprecation
    • Mark entity as deprecated without removing it

Depending of the context, some other changes could be applied but with prudence:

  • Add new enumerators to a previous defined enum
  • Remove private non-virtual functions
  • Remove private static members
  • Remove inline specifier of a function

More examples can be found on the KDE policy pages: Binary Compatibility Issues With C++ and Binary Compatibility Examples.

Allowed changes in a MAJOR incrementation

Finally, the MAJOR component is reserved for incompatible API changes.

Of course, incrementaton of MAJOR should be used with parsimony in order to limit the number of changes required by software depending of the library. Let’s imagine a scenario where a developer of a widely used library release a new version with an incrementation of the MAJOR component each week. Downstream developers will have to update their software each week to adapt to the new API library. There is good chance that they will move to a another library with similar features and with a more stable API.

Back to our foo library with the following change:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>
#include <ctime>

using namespace std;

-void CurrentTime() {
+void CurrentTime(time_t rawtime) {

-   time_t rawtime = time(0);

   char *dt = ctime(&rawtime);
   cout << "Current local time: " << dt << endl;
}

-void CurrentGMTTime() {
+void CurrentGMTTime(time_t rawtime) {

-   time_t rawtime = time(0);

   tm *g = gmtime(&rawtime);
   dt = asctime(g);
   cout << "Current GMT time: "<< dt << endl;
}

int main()
{
+   time_t rawtime = time(0);
+   CurrentTime(rawtime);
-   CurrentTime();
}

This change is clearly an API break, then in this case we need to increment the MAJOR component and we will release a new version 3.0.0.

A non-exhaustive list of changes breaking the API:

  • Classes
    • Unexport an exported class
    • Remove an exported class
    • Modify the class hierarchy (Add, Remove or change order)
    • Remove the class finality
    • Re-ordering class and struct members
  • Template Classes
    • Modify template arguments (Add, Remove or change order)
  • Functions
    • Unexport
    • Remove
    • Inline
    • Change a parameter
    • Change the return type
    • Change the access rights
    • Change the const and volatile qualifiers of a member function
    • Change the const and volatile qualifiers of global data
    • Change the type of global data
  • Virtual Member Functions
    • Add a new virtual member function
    • Change the declaration order of virtual functions
    • Override a virtual

More examples can be found on the KDE policy pages: Binary Compatibility Issues With C++ and Binary Compatibility Examples.

How to deprecate functionality?

The functionality deprecation should follow a deprecation process in order to make the API breakage as smooth as possible for downstream developers using the deprecated functionality.

The first step is to update the documentation to let the users know regarding the API changes. The documentation must contain which functionality will be deprecated and how to move to the replacement one. More detailed is the document, quicker users will move to the new API.

The next step is to release at least one minor version with deprecation notices (for instance displaying warnings at build time). In C and C++, the deprecated attribute permits to mark a name or entity as deprecated. In other words, its usage is allowed but discouraged. Please note the syntax of deprecated has changed with C++14 or C23.

Compilers will display the warning. However the default behavior can be overridden using the -Wdeprecated-declarations and -Wno-deprecated-declarations options to respectively turn on and off the warning. Is it possible to turn the deprecation warnings into errors using the -Werror=deprecated-declarations option.

See below a basic example in C++14:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#include <iostream>

[[deprecated]]
void DebianStretch() {
    std::clog << "Debian Stretch: https://wiki.debian.org/DebianStretch\n";
}

[[deprecated("Use Apertis2022() instead.")]]
void Apertis2020() {
    std::clog << "Apertis 2020: https://www.apertis.org/release/v2020.7/releasenotes/\n";
}

int main()
{
    DebianStretch();
    Apertis2020();
}

The same example with the pre-C++14 syntax:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#include <iostream>

__attribute__((deprecated)) void DebianStretch() {
    std::clog << "Debian Stretch: https://wiki.debian.org/DebianStretch\n";
}

__attribute__((deprecated("Use Apertis2022() instead."))) void Apertis2020() {
    std::clog << "Apertis 2020: https://www.apertis.org/release/v2020.7/releasenotes/\n";
}

int main()
{
    DebianStretch();
    Apertis2020();
}

Now, the equivalent in pre-C23:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#include <stdio.h>

__attribute__((deprecated)) void DebianStretch(void)
{
    puts("Debian Stretch: https://wiki.debian.org/DebianStretch");
}

__attribute__((deprecated("Use Apertis2022() instead."))) void Apertis2020(void)
{
    puts("Apertis 2020: https://www.apertis.org/release/v2020.7/releasenotes/");
}

int main(void)
{
    DebianStretch();
    Apertis2020();
}

This example will give the following output:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
foo@bar:~$ g++ test_deprecated.cpp
test_deprecated.cpp: In function ‘int main()’:
test_deprecated.cpp:15:19: warning: ‘void DebianStretch()’ is deprecated [-Wdeprecated-declarations]
   15 |     DebianStretch();
      |                   ^
test_deprecated.cpp:4:6: note: declared here
    4 | void DebianStretch() {
      |      ^~~~~~~~~~~~~
test_deprecated.cpp:16:17: warning: ‘void Apertis2020()’ is deprecated: Use Apertis2022() instead. [-Wdeprecated-declarations]
   16 |     Apertis2020();
      |                 ^
test_deprecated.cpp:9:6: note: declared here
    9 | void Apertis2020() {
      |      ^~~~~~~~~~~

Let’s turn the warnings into errors:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
foo@bar:~$ gcc test_deprecated.c -Werror=deprecated-declarations
test_deprecated.c: In function ‘main’:
test_deprecated.c:17:5: error: ‘DebianStretch’ is deprecated [-Werror=deprecated-declarations]
   17 |     DebianStretch();
      |     ^~~~~~~~~~~~~
test_deprecated.c:4:6: note: declared here
    4 | void DebianStretch(void)
      |      ^~~~~~~~~~~~~
test_deprecated.c:18:5: error: ‘Apertis2020’ is deprecated: Use Apertis2022() instead. [-Werror=deprecated-declarations]
   18 |     Apertis2020();
      |     ^~~~~~~~~~~
test_deprecated.c:10:6: note: declared here
   10 | void Apertis2020(void)
      |      ^~~~~~~~~~~
cc1: some warnings being treated as errors

This allows users to know that a future change will break the API and they will have to update accordingly their downstream software. Ideally, if the old and the new functionalities can coexist without breaking the existing API, then it is the right time to release a new minor version allowing a transition period.

And finally, after some time, a new major version including the removed functionality (thus the API break) can be released.

Practical example of deprecation

Let’s take an example, we develop a library currently in version 0.2.3 providing the get_attibute() function that we want to replace by get_attribute_with_meta().

  • 0.2.3 → bugs fix release: increase of the PATCH component
  • 0.2.4 → bugs fix release: increase of the PATCH component
  • 0.3.0 → deprecates get_attibute() and add get_attribute_with_meta(): new functionality added in a backwards compatible manner, the previous get_attibute() is still available: increase of the MINOR component
  • 0.3.1 → bugs fix release: increase of the PATCH component
  • 0.3.2 → bugs fix release: increase of the PATCH component
  • 1.0.0 → remove get_attibute(): this version remove the previous function, this is an API breakage: increase of the MAJOR component
  • 1.0.1 → bugs fix release: increase of the PATCH component
  • 1.0.2 → bugs fix release: increase of the PATCH component

The removal timeline depends on several factors like how many API changes have already been done in the previous versions and how many work is required for developers of reverse dependencies to adapt their code to the new API. If only small changes are required to use the new API, then the time to remove the old API can be reduced, but if the required changes are big and require a lot of work, then the time to remove the old API can be increased. This decision is based on the previous factors and on the common sense.

How to check API/ABI breakage?

Several tools can be used to analyze changes in API/ABI and to detect breakage issues:

  • apertis-abi-compare: A wrapper around the ABI Compliance Checker tool to compare API/ABI of two versions of the same library package. This tool is provided in Apertis by the apertis-dev-tools (>= 0.2021.11) package .
  • ABI Compliance Checker: A tool for checking backward API/ABI compatibility of a C/C++ library. Available in Apertis in the abi-compliance-checker package.
  • ABIGAIL framework: framework which aims at helping developers and software distributors to spot some ABI-related issues like interface incompatibility.

The early use of these tools is highly recommended to detect breakage as soon as possible during development.

An extension of the Apertis GitLab build pipeline is available to automatically check for API/ABI breakage for new package releases. Please refer to the How to check for API breakage documentation to enable it for a package. Detecting a breakage only at this step is late, but it allows at least to take the necessary actions in respect to dependencies (update, rebuild, etc) or to quickly release another version restoring the backwards compatibility.

References