Search code examples
c++floating-pointcompiler-flagscoercionaccumulate

Why does this example involving std::accumulate compile (badly), and how to guard against misuse?


Surprinsingly (for me), this compiles:

    std::vector<int> v;
    std::accumulate(std::cbegin(v), std::cend(v), 0, [](double sum, auto i){
        return sum + 0.1;
    });

(gcc 12 with --std=c++20a)

This is bad, because if I look at the signature of std::accumulate, it returns what's passed in the init argument, which in my case is the integer 0. But obviously, the passed lambda works with (takes and returns) doubles. So there must be some double to int coercion happening somewhere, that the compiler, surprinsingly, allows.

To ensure proper behavior, my code now reads std::accumulate(std::cbegin(v), std::cend(v), 0.0,..., (notice the 0.0). But this is unsatisfactory because next time when I'm less careful, I will write just 0 not 0.0.

I would like to know why does GCC allow this and is there a way (I suspect a compiler flag) to prevent this from compiling?


Solution

  • This is the defined behavior of std::accumulate. It will initialize the accumulator with the initial value provided and take on its type. Then it will repeatedly add new values with the accumulator, always assigning the intermediate results to the accumulator object. The assignment coerces the double result of the lambda into a int (the type of the accumulator). This is allowed, because C++ always allows implicit double-to-int conversion. Similarly while adding, the current value of the accumulator will be implicitly converted to double to fit the lambda's parameter.

    std::accumulate is not defined to perform any check on the types so that no such conversion happens. If you want that, you will need to write your own wrapper around std::accumulate enforcing these constraints, for example:

    // doesn't cover some edge cases
    
    // prevents only conversion in the accumulator assignment
    // not in the `op` arguments
    
    template<typename It, typename T, typename O>
    T my_accumulate(It first, It last, T init, O op) {
        static_assert(std::is_same_v<decltype(op(std::move(init), *first)), T>, "Incompatible init type!");
        return std::accumulate(first, last, init, op);
    }
    

    The lambda may also be used to enforce stricter typing in various ways. The comments under the question list some possibilities:

    • Use double& instead of double as type. This enforces that the accumulator has type double, because otherwise the reference cannot bind to it. However, const double& or double&& won't work, since these are allowed to bind to temporaries, which would again be created implicitly.

    • Declare the type as auto instead of double. This will guarantee that the type is deduced to the type of the accumulator. Then static_assert with std::is_same that it is deduced to double.

    • (Since C++20) Declare the type as std::same_as<double> auto instead of double which has the same effect as above, but instead of a hard failure during instantiation it will cause overload resolution on the lambda to fail due to constrain violation.

    As far as I know there is no warning flag in common compilers that could warn about this, because the conversion happens in an implicit instantiation from a template, where e.g. the -Wconversion flag and similar warning flags are usually not applied to avoid too much noise.