Summary
std::vector and the unsigned length and subscript problem
Highlights
The C++ standard library, designed around 1997, chose unsigned types for container lengths and array subscripts. This decision was based on reasons like non-negative subscripts, greater length capacity due to an extra bit in 16-bit systems, and simplified range-checking. However, this choice is now largely considered a mistake. Unsigned values for non-negativity fail due to implicit type conversion, the extra bit capacity is often unnecessary on modern systems, and `operator[]` doesn't perform range-checking anyway. This creates unnecessary complexity, particularly when mixing signed and unsigned values, which is generally discouraged.
Sign conversions (between signed and unsigned types) are typically considered narrowing conversions because neither type can hold all values of the other. At runtime, such conversions can cause errors in contexts like list initialization or warnings in others. However, if the value being converted is `constexpr` and can be safely represented in the target type, the conversion is not considered narrowing and can occur implicitly. This `constexpr` conversion is a key mechanism used to avoid issues when dealing with `std::size_t`.
Standard library container classes, including `std::vector`, define a nested typedef called `size_type`. This `size_type` is an alias for the type used to represent the container's length and, if supported, its indices. It almost always defaults to `std::size_t`, which is a large unsigned integral type (e.g., `unsigned long` or `unsigned long long`). Understanding `size_type` is crucial because it governs the types expected for functions like `size()` and for array subscripting.
The `size()` member function returns the length of a `std::vector` as a `size_type`. Since C++17, the non-member function `std::size()` can also be used, which internally calls the `size()` member function for container classes. Both return an unsigned type. To store this length in a signed `int` variable, a `static_cast` is recommended to avoid warnings or errors due to the implicit unsigned-to-signed conversion. C++20 introduced `std::ssize()`, which returns the length as a large signed integral type (typically `std::ptrdiff_t`), providing a more convenient way to work with signed lengths, either by `static_cast`ing to `int` or using `auto` for type deduction.
Elements of a `std::vector` can be accessed using `operator[]` or the `at()` member function. `operator[]` provides direct access but performs no bounds checking, leading to undefined behavior if an invalid index is used. In contrast, `at()` performs runtime bounds checking and throws an `std::out_of_range` exception if the index is invalid, thus terminating the program if unhandled. While `at()` is safer, it is slower due to the overhead of checks, so `operator[]` is often preferred when bounds checking can be done proactively before indexing.
When indexing `std::vector` with a `constexpr` signed `int`, the compiler implicitly converts it to `std::size_t` without a narrowing conversion issue, as the conversion is safe and known at compile time. However, indexing with a non-`constexpr` signed `int` can lead to narrowing conversion warnings because the `operator[]` expects a `size_type` (unsigned). To avoid these warnings, one can `static_cast` the index to `std::size_t` or use a variable of type `std::size_t` for indexing. Another solution is to index the result of the `data()` member function (`prime.data()[index]`), which returns a pointer to the underlying C-style array, bypassing sign conversion warnings since C-style arrays support both signed and unsigned indices.