This post is a part of my C++26 exploration series where I take a new feature and try to understand and explain with a simple example in hand. Today’s topic is Contracts. First we will simply try to understand what is the problem it is solving then try doing some assessment on value addition.
The Current Issue without Contract
So let us take a quick look at the following example. It is a code snippet taken from a ring buffer implementation. The full source code for the ring buffer can be found here.
void write(const std::span<const std::byte> payload)
{
const auto payload_length = static_cast<std::uint32_t>(payload.size());
const std::size_t data_size = k_header_max_size + payload.size();
std::cout << "write: head " << m_head << ", data_size " << data_size;
std::byte header[k_header_max_size]{};
std::memcpy(header, &payload_length, k_header_max_size);
for (std::size_t i = 0U; i < k_header_max_size; ++i)
{
store_byte(m_head + i, header[i]);
}
for (std::size_t i = 0U; i < payload.size(); ++i)
{
store_byte(m_head + k_header_max_size + i, payload[i]);
}
m_head = (m_head + data_size) % m_storage.size();
m_used = std::min(m_used + data_size, m_storage.size());
std::cout << " -> " << m_head << '\n';
}This function receives a payload, stores the size of the payload in a header of type std::uint32_t and of size k_header_max_size. k_header_max_size is define as the following:
static constexpr std::size_t k_header_max_size = sizeof(std::uint32_t);The header length is written first and then the payload is written into the buffer. So each data item consists of the following:
data_size = header + payload
The write function is part of the class ring_buffer which keeps track of the head, tail and used space using the following data members:
std::size_t m_head{};std::size_t m_tail{};std::size_t m_used{};
And the buffer storage is represented by the following vector of type std::byte:
std::vector<std::byte> m_storage;And the max size of the ring buffer is given by the following in the ring_buffer class:
constexpr std::size_t buffer_capacity = 128U;Now from main() we can do the following:
int main()
{
ring_buffer buffer{buffer_capacity};
const std::vector<std::byte> initial_record(40U,
static_cast<std::byte>('A'));
const std::vector<std::byte> overflow_record(100U,
static_cast<std::byte>('B'));
buffer.write(initial_record);
buffer.write(overflow_record);
...
...
}So first we write a data item of size 40 bytes and another of size 100 bytes. The total capacity of the buffer is 128 bytes whereas we have written total of 40 + 100 bytes of data plus their header size which is total of 8 more bytes. That makes it total of 128 bytes been written into the buffer. There is a function in the ring buffer class which can read the data written into the ring buffer as the following:
[[nodiscard]] std::size_t read(const std::span<std::byte> out)
{
if (m_used == 0U)
{
return 0U;
}
std::byte header[k_header_max_size]{};
for (std::size_t i = 0U; i < k_header_max_size; ++i)
{
header[i] = load_byte(m_tail + i);
}
std::uint32_t length{};
std::memcpy(&length, header, k_header_max_size);
const std::size_t payload = std::min<std::size_t>(length, out.size());
for (std::size_t i = 0U; i < payload; ++i)
{
out[i] = load_byte(m_tail + k_header_max_size + i);
}
const std::size_t data_size = k_header_max_size + payload;
m_tail = (m_tail + data_size) % m_storage.size();
m_used -= std::min(m_used, data_size);
return payload;
}and another function which prints the ring buffer content:
void print_preview(const std::string_view label, const std::span<const std::byte> bytes)
{
std::cout << label << " (" << bytes.size() << " bytes): ";
const std::size_t preview = std::min<std::size_t>(bytes.size(), 16U);
for (std::size_t i = 0U; i < preview; ++i)
{
std::cout << static_cast<char>(bytes[i]);
}
if (bytes.size() > preview)
{
std::cout << "...";
}
std::cout << '\n';
}The full source code is available here. Compile and run the code as follows:
$ g++ -std=c++23 \
-Wall \
-Wextra \
-Wpedantic \
ring_buffer_without_contracts.cpp \
-o ring_buffer_without_contracts
$ ./ring_buffer_without_contracts
capacity = 128
write: head 0, data_size 44 -> 44
wrote first record, available now = 80
writing a 100-byte record with only 80 bytes available
write: head 44, data_size 104 -> 20
--- reading back the first record ---
expected (40 bytes): AAAAAAAAAAAAAAAA...
actual (128 bytes): BBBBBBBBBBBBBBBB...So after the second write when we print the content of the ring buffer we can see the content has been overwritten. The reason being there is no check at the moment to verify of there is enough space in the ring buffer to accommodate new writes.
Current Solution
So we can do to prevent this? We can introduce a new function in the class which can give us the available space. The following is an implementation of available space:
[[nodiscard]] std::size_t available() const noexcept
{
const std::size_t free_bytes = m_storage.size() - m_used;
return free_bytes > k_header_max_size ? free_bytes - k_header_max_size : 0U;
}Now we can call this available() function and check if there is space available for the write operation with the request size, and if not we simply do not allow the write operation. So a modified version of the write() function could be as follows:
void write(const std::span<const std::byte> payload)
{
const auto payload_length = static_cast<std::uint32_t>(payload.size());
const std::size_t data_size = k_header_max_size + payload.size();
if (data_size < available())
{
std::byte header[k_header_max_size]{};
std::memcpy(header, &payload_length, k_header_max_size);
for (std::size_t i = 0U; i < k_header_max_size; ++i)
{
store_byte(m_head + i, header[i]);
}
for (std::size_t i = 0U; i < payload.size(); ++i)
{
store_byte(m_head + k_header_max_size + i, payload[i]);
}
m_head = (m_head + data_size) % m_storage.size();
m_used = std::min(m_used + data_size, m_storage.size());
std::cout << " -> " << m_head << '\n';
}
}Now with this modification in place if we compile and run it again we will see the following:
$ g++ -std=c++23 -Wall -Wextra -Wpedantic ring_buffer_without_contracts.cpp -o ring_buffer_without_contracts
$ ./ring_buffer_without_contracts
capacity = 128
write: head 0, data_size 44 -> 44
wrote first record, available now = 80
writing a 100-byte record with only 80 bytes available
--- reading back the first record ---
expected (40 bytes): AAAAAAAAAAAAAAAA...
actual (40 bytes): AAAAAAAAAAAAAAAA...So the earlier issue of overwriting is gone. The additional check that we introduced in the write function is kind of voluntary and there is no possible way to enforce it.
The contracts way
Contracts in C++26 is giving us an option to make it a binding requirement at the function declaration. We can rewrite the write() function in the class in the following way using contracts in C++26:
void write(const std::span<const std::byte> record)
pre(!record.empty())
pre(record.size() <= available())
{
const auto length = static_cast<std::uint32_t>(record.size());
std::byte header[header_size]{};
std::memcpy(header, &length, header_size);
for (std::size_t i = 0U; i < header_size; ++i)
{
store_byte(m_head + i, header[i]);
}
for (std::size_t i = 0U; i < record.size(); ++i)
{
store_byte(m_head + header_size + i, record[i]);
}
const std::size_t frame_size = header_size + record.size();
m_head = (m_head + frame_size) % m_storage.size();
m_used = std::min(m_used + frame_size, m_storage.size());
}Note the introduction of two precondition in the function declaration:
void write(const std::span<const std::byte> record)
pre(!record.empty())
pre(record.size() <= available())The first condition makes sure the record that being written is not empty and the second one makes sure there is enough space in the ring buffer to accommodate the new write.
Similarly, we can introduce some post condition in the read() function as the following:
[[nodiscard]] std::size_t read(const std::span<std::byte> out)
post(bytes_read : bytes_read <= out.size())
{
if (m_used == 0U)
{
return 0U;
}
std::byte header[header_size]{};
for (std::size_t i = 0U; i < header_size; ++i)
{
header[i] = load_byte(m_tail + i);
}
std::uint32_t length{};
std::memcpy(&length, header, header_size);
const std::size_t payload = std::min<std::size_t>(length, out.size());
for (std::size_t i = 0U; i < payload; ++i)
{
out[i] = load_byte(m_tail + header_size + i);
}
const std::size_t frame_size = header_size + payload;
m_tail = (m_tail + frame_size) % m_storage.size();
m_used -= std::min(m_used, frame_size);
return payload;
}The full source code is available here. We can compile and run this code like the following:
g++ -std=c++26 \
-fcontracts \
-fcontract-evaluation-semantic=enforce \
-Wall \
-Wextra \
-Wpedantic \
ring_buffer_with_contracts.cpp \
-o ring_buffer_with_contracts
$ ./ring_buffer_with_contracts
capacity = 128
wrote first record, available now = 80
writing a 100-byte record with only 80 bytes available
contract violation in function void ring_buffer::write(std::span<const std::byte>) at ring_buffer_with_contracts.cpp:30: record.size() <= available()
[assertion_kind: pre, semantic: enforce, mode: predicate_false, terminating: yes]
terminate called without an active exception
Aborted (core dumped)As you can see the restriction have been enforced and as the contract broken by the second attempt to write, the program has been terminated. Note while compiling the code we have used the following flag:
-fcontract-evaluation-semantic=enforceIt enforces the pre and the post conditions. But there can be other modes of contracts as the following:
- ignored: Contract checks are ignored.
- observed: The condition is checked and the violation handler is called. Execution continues if the handler returns.
- enforce: The condition is checked and the violation handler is called. The program then terminates.
- quick_enforce: The condition is checked and the program terminates immediately without calling the violation handler.
Custom violation handler
We have not defined any violation handler so far. Let’s define the violation handler function called handle_contract_violation as follows:
void handle_contract_violation(const std::contracts::contract_violation&
violation)
{
const auto location = violation.location();
std::cerr << "\nCustom contract violation handler\n"
<< "Predicate: "
<< violation.comment()
<< '\n'
<< "Location: "
<< location.file_name()
<< ':'
<< location.line() << '\n';
throw contract_violation_error{violation.comment()};
}Whenever a contract violation happens, the C++26 runtime will call the handle_contract_violation() with the reference to std::contracts::contract_violation. The std::contracts::contract_violation has the information like comment, location which contains the file_name() and line number etc. This handler is throwing a custom exception which is called contract_violation_error and defined as below:
class contract_violation_error final : public std::runtime_error
{
public:
explicit contract_violation_error(const char* predicate)
: std::runtime_error(predicate)
{
}
};The full source code can be found here. Now, if there is a contract violation your program need not necessarily need to be terminated, it can be handled as well.
What is the value add?
I have been trying to explore more on this but as far as I can see there are particularly two value addition by introduction of contracts:
One, the contracts, the pre and the post conditions are explicit, can be enforced with manual checks and assertion. This is great for writing clean code. Rather than having manual checks everywhere throughout the function, we can think it through, decide the binding requirements and then everything else will be taken care automatically.
Second, the API becomes lot more clearer. The function now explicitly tells the user what the pre and the post conditions are. Earlier the only way to communicate to the user was inline commenting and API documentation. Now with contract that would become lot more clearer to the user of the API and easier for the developer to communicate via the code itself.
Third, static analysis would be better with added explicit pre and post conditions in the declaration itself.
Anyone knows about any other benefits of contract please feel free to let me know.
The source code is available from this git repo.
C++26 Series
Follow my C++26 series as I explore the language’s new features through practical examples.- 01 Compile Your First C++26 Program with GCC 16.1
-
02
C++26: What Is
template for? - 03 C++26: What Is Reflection and How Do You Use It?
- 04 C++26 Reflection: Simplifying JSON Serialization
- 05 C++26 Reflection Annotations: Automated Member Validation
- 06 C++26 Contracts: What Do They Add Beyond Manual Checks and Assertions?
- 07 C++26 in GCC 16 Zero-Fills Your Local Variables: Time to Recompile Your C++ Programs
Discover more from Tech For Talk
Subscribe to get the latest posts sent to your email.
5 Comments