#039 – Building a Binary-to-decimal converter

Introduction

A binary number is easy for a computer to store, but not always easy for a human to read. The number 1101 is clear once you know binary, but most people recognise its decimal equivalent, 13, much faster.

This Nibble walks through a small C++ program that converts a binary string into an unsigned decimal integer. The goal is not merely to perform the conversion, but to show the structure of a careful command-line program: validate the input, reject malformed data, detect overflow, and only print a result once the program knows the conversion is safe.

Three things to take away:

  • Binary-to-decimal conversion can be performed left-to-right using repeated multiplication by 2.
  • Input validation should happen before conversion, not after the result has already been computed.
  • Integer overflow must be checked before it occurs, because unsigned overflow silently wraps around.

The theory

Binary is a base-2 numbering system. Each digit is either 0 or 1, and each position represents a power of two.

For example:

1011₂ = (1 × 2³) + (0 × 2²) + (1 × 2¹) + (1 × 2⁰)
      = 8 + 0 + 2 + 1
      = 11

One way to convert binary to decimal is to multiply each bit by its positional weight. That works, but it requires thinking about powers of two and bit positions.

A more program-friendly method is to process the binary number from left to right:

decimal = decimal * 2 + bit

For 1011, the steps are:

Start: decimal = 0

Read 1: decimal = 0 * 2 + 1 = 1
Read 0: decimal = 1 * 2 + 0 = 2
Read 1: decimal = 2 * 2 + 1 = 5
Read 1: decimal = 5 * 2 + 1 = 11

This works because each new binary digit shifts the existing value one place to the left, then adds the new bit.

In this refactored version, the same idea is written using bitwise operators:

decimal = (decimal << 1) | bit;

This is equivalent to:

decimal = decimal * 2 + bit;

decimal << 1 shifts the current value left by one binary place, which is the same as multiplying by two for unsigned integers. The | bit then inserts the new bit into the lowest position.

So the conversion rule becomes:

Shift the current value left by one bit, then insert the next input bit.

That is a natural fit for a binary conversion program.

The program

#include <algorithm>
#include <format>
#include <iostream>
#include <limits>
#include <optional>
#include <string>
#include <string_view>

bool is_binary_number(std::string_view input){
    return !input.empty() && std::ranges::all_of(input, [](char c) {
        return c == '0' || c == '1';
    });
}

std::string format_binary(std::string_view binary){
    std::string formatted;
    for (std::size_t i = 0; i < binary.size(); ++i) {
        if (i > 0 && i % 4 == 0) {
            formatted += ' ';
        }
        formatted += binary[i];
    }
    return formatted;
}

std::optional<unsigned long long> binary_to_decimal(std::string_view binary){
    unsigned long long decimal = 0;
    for (char digit : binary) {
        unsigned long long bit = static_cast<unsigned long long>(digit - '0');

        // Overflow check using modern numeric_limits
        if (decimal > (std::numeric_limits<unsigned long long>::max() - bit) / 2) {
            return std::nullopt;
        }

        decimal = (decimal << 1) | bit; // Bitwise operators for clarity in binary context
    }
    return decimal;
}

int main(){
    std::string input;
    auto has_valid_length = [](std::string_view s) { return s.size() % 4 == 0; };

    while (true) {
        std::cout << "Enter a binary number: ";
        if (!(std::cin >> input)) {
            std::cout << "\nNo input provided.\n";
            return 1;
        }

        if (!is_binary_number(input)) {
            std::cout << "Error: Only 0s and 1s allowed.\n\n";
            continue;
        }

        if (!has_valid_length(input)) {
            std::cout << "Error: Length must be divisible by 4.\n\n";
            continue;
        }

        auto result = binary_to_decimal(input);
        if (!result) {
            std::cout << "Error: Number exceeds ULLONG_MAX.\n\n";
            continue;
        }

        // C++20 std::format for clean output
        std::cout << std::format("\nThe decimal equivalent of {} is {}\n",
                                 format_binary(input), *result);
        break;
    }
}

The program is deliberately split into small functions:

FunctionResponsibility
is_binary_number()Rejects empty input and non-binary characters.
has_valid_binary_length()Requires the input length to be divisible by 4.
binary_to_decimal()Converts the validated binary string into decimal.
format_binary()Inserts a space after every four bits for readability.
main()Coordinates input, validation, conversion, and output.

Interpretation

The program begins by validating whether the input is genuinely binary:

bool is_binary_number(std::string_view input){
    return !input.empty() && std::ranges::all_of(input, [](char c) {
        return c == '0' || c == '1';
    });
}

This function accepts a std::string_view, not a const std::string&. That is appropriate because the function only needs to inspect the characters. It does not need ownership, mutation, or storage. A std::string_view can refer to a std::string, a string literal, or another contiguous character sequence without copying.

The predicate passed to std::ranges::all_of checks each character:

[](char c) {
    return c == '0' || c == '1';
}

The function returns true only when the input is non-empty and every character is either 0 or 1. This is more declarative than writing a manual loop because the function name and algorithm name together state the intent: all characters must satisfy the binary-digit rule.

Next, the program formats the binary string into groups of four using format_binary for readability.

The main conversion function returns an std::optional<unsigned long long>:

std::optional<unsigned long long> binary_to_decimal(std::string_view binary)

This is a cleaner interface than returning bool and writing the result through an output parameter. The older style would look like this:

bool binary_to_decimal(std::string_view binary, unsigned long long& decimal);

That design makes the caller pass in a variable to be modified. The refactored version instead says:

The function may return a decimal value.
If conversion fails, it returns no value.

That is exactly what std::optional represents.

Inside the function, the decimal result starts at zero:

unsigned long long decimal = 0;

Then each binary digit is converted from a character to a numeric bit:

unsigned long long bit = static_cast<unsigned long long>(digit - '0');

This works because the characters '0' and '1' have adjacent character codes. Therefore:

'0' - '0' = 0
'1' - '0' = 1

The cast makes the type explicit before the bit is used in unsigned arithmetic.

Before shifting and inserting the bit, the program checks whether the next step would overflow:

if (decimal > (std::numeric_limits<unsigned long long>::max() - bit) / 2) {
    return std::nullopt;
}

This guards the operation:

decimal = decimal * 2 + bit;

or, in the refactored code:

decimal = (decimal << 1) | bit;

The check asks:

Is decimal already too large to be doubled and have this bit added?

If yes, the function returns std::nullopt. That prevents unsigned overflow. Although unsigned overflow is well-defined in C++, it wraps around modulo the maximum representable value plus one. That would produce the wrong decimal result, so the program rejects the input instead.

The actual update step is concise:

decimal = (decimal << 1) | bit;

For each digit, the existing value is shifted left by one binary position, then the current bit is inserted into the least significant position.

For the input 1011, the process is:

Start: decimal = 0

Read 1:
(0 << 1) | 1 = 1

Read 0:
(1 << 1) | 0 = 2

Read 1:
(2 << 1) | 1 = 5

Read 1:
(5 << 1) | 1 = 11

The algorithm does not need to know the total length of the binary number. It simply accumulates the result as each bit arrives.

The main() function then coordinates input, validation, conversion, and output:

while (true) {
    std::cout << "Enter a binary number: ";
    if (!(std::cin >> input)) {
        std::cout << "\nNo input provided.\n";
        return 1;
    }

    if (!is_binary_number(input)) {
        std::cout << "Error: Only 0s and 1s allowed.\n\n";
        continue;
    }

    if (!has_valid_length(input)) {
        std::cout << "Error: Length must be divisible by 4.\n\n";
        continue;
    }

    auto result = binary_to_decimal(input);
    if (!result) {
        std::cout << "Error: Number exceeds ULLONG_MAX.\n\n";
        continue;
    }

    std::cout << std::format("\nThe decimal equivalent of {} is {}\n",
                             format_binary(input), *result);
    break;
}

The order of validation matters:

  1. First, check that input exists.
  2. Then, check that it contains only binary digits.
  3. Then, check that its length matches the formatting policy.
  4. Then, attempt conversion.
  5. Finally, print the result.

Each failed validation uses continue to restart the loop. This keeps the successful path clean: once the program reaches the bottom of the loop, it has a valid binary string and a valid decimal result.

Conclusion

The conversion algorithm itself is the central lesson:

decimal = (decimal << 1) | bit;

Read left to right, shift the accumulated value one binary place, then insert the next bit. That single line captures the whole binary-to-decimal conversion process. The surrounding code makes the program reliable: it rejects invalid input, preserves readable formatting, prevents overflow, and returns failure explicitly instead of smuggling it through an output parameter