4

I was working on some JSON parsing code using the lovely nlohmann::json, and to help make useful error messages, I made myself a function to print the type of a JSON object. This function accepts a json::value_t, which is an enum class defined exactly as follows in json.hpp:

enum class value_t : std::uint8_t {
    null,
    object,
    array,
    string,
    boolean,
    number_integer,
    number_unsigned,
    number_float,
    discarded
};

Here's my function. I pass it a json::value_t and I expect to receive a string that describes it.

std::string to_string(json::value_t type){
    static const std::map<json::value_t, std::string> mapping = {
        {json::value_t::null,            "null"},
        {json::value_t::object,          "an object"},
        {json::value_t::array,           "an array"},
        {json::value_t::string,          "a string"},
        {json::value_t::boolean,         "a boolean"},
        {json::value_t::number_integer,  "an integer"},
        {json::value_t::number_unsigned, "an unsigned integer"},
        {json::value_t::number_float,    "a floating point number"}
    };
    auto it = mapping.find(type);
    if (it != mapping.end()){
        return it->second;
    }
    return "a mystery value";
}

But, while debugging in Visual Studio, I was really spooked when this function returned the string "an integer" when I very certainly passed it json::value_t::number_float.

Fearing the worst, and wanting a quick fix, I wrote the following alternative, which is identical except that the enum is always cast to its underlying type before it is used:

std::string to_string_with_cast(json::value_t type){
    using ut = std::underlying_type_t<json::value_t>;
    static const std::map<ut, std::string> mapping = {
        {static_cast<ut>(json::value_t::null),            "null"},
        {static_cast<ut>(json::value_t::object),          "an object"},
        {static_cast<ut>(json::value_t::array),           "an array"},
        {static_cast<ut>(json::value_t::string),          "a string"},
        {static_cast<ut>(json::value_t::boolean),         "a boolean"},
        {static_cast<ut>(json::value_t::number_integer),  "an integer"},
        {static_cast<ut>(json::value_t::number_unsigned), "an unsigned integer"},
        {static_cast<ut>(json::value_t::number_float),    "a floating point number"}
    };
    auto it = mapping.find(static_cast<ut>(type));
    if (it != mapping.end()){
        return it->second;
    }
    return "a mystery value";
}

This worked. Passing a json::value_t::number_float resulted in "a floating point number", as I expected.

Still curious, and suspecting one of Microsoft's quirks or Undefined Behavior lurking elsewhere in my fairly large code base, I ran the following test on g++:

    std::cout << "Without casting enum to underlying type:\n";
    std::cout << "null:   " << to_string(json::value_t::null) << '\n';
    std::cout << "object: " << to_string(json::value_t::object) << '\n';
    std::cout << "array:  " << to_string(json::value_t::array) << '\n';
    std::cout << "string: " << to_string(json::value_t::string) << '\n';
    std::cout << "bool:   " << to_string(json::value_t::boolean) << '\n';
    std::cout << "int:    " << to_string(json::value_t::number_integer) << '\n';
    std::cout << "uint:   " << to_string(json::value_t::number_unsigned) << '\n';
    std::cout << "float:  " << to_string(json::value_t::number_float) << '\n';

    std::cout << "\nWith casting enum to underlying type:\n";
    std::cout << "null:   " << to_string_with_cast(json::value_t::null) << '\n';
    std::cout << "object: " << to_string_with_cast(json::value_t::object) << '\n';
    std::cout << "array:  " << to_string_with_cast(json::value_t::array) << '\n';
    std::cout << "string: " << to_string_with_cast(json::value_t::string) << '\n';
    std::cout << "bool:   " << to_string_with_cast(json::value_t::boolean) << '\n';
    std::cout << "int:    " << to_string_with_cast(json::value_t::number_integer) << '\n';
    std::cout << "uint:   " << to_string_with_cast(json::value_t::number_unsigned) << '\n';
    std::cout << "float:  " << to_string_with_cast(json::value_t::number_float) << '\n';
}

And I was really spooked to see the same behavior as Visual Studio:

Without casting enum to underlying type:
null:   null
object: an object
array:  an array
string: a string
bool:   a boolean
int:    an integer
uint:   an integer
float:  an integer
With casting enum to underlying type:
null:   null
object: an object
array:  an array
string: a string
bool:   a boolean
int:    an integer
uint:   an unsigned integer
float:  a floating point number

Why is this happening? It appears that number_float and number_unsigned are both considered equal to number_integer. But according to this answer, there is nothing special about comparing a normal enum. Is anything different about using an enum class? Is this standard behavior?


EDIT: Here's a much simpler source of confusion: It appears that if I use < to compare any pair of the last three enum class values, it always returns false. This is probably the heart of my issue above. Why is there this strange behavior? The following output is from this live example

number_integer  < number_integer  : false
number_integer  < number_unsigned : false
number_integer  < number_float    : false
number_unsigned < number_integer  : false
number_unsigned < number_unsigned : false
number_unsigned < number_float    : false
number_float    < number_integer  : false
number_float    < number_unsigned : false
number_float    < number_float    : false
null            < number_integer  : true
null            < number_unsigned : true
null            < number_float    : true
bool            < number_integer  : true
bool            < number_unsigned : true
bool            < number_float    : true
alter_igel
  • 6,899
  • 3
  • 21
  • 40
  • 3
    You may have missed the free `operator<` for `value_t`. – Retired Ninja Jun 14 '19 at 01:37
  • It would improve the question to post a [Minimal, Reproducible Example](https://stackoverflow.com/help/minimal-reproducible-example) , instead of a bunch of snippets – M.M Jun 14 '19 at 01:37
  • @RetiredNinja You are correct. I should have scrolled further down in the source code. – alter_igel Jun 14 '19 at 01:38
  • 3
    @RetiredNinja ouch... seems like a blunder from what's normally considered a well-designed library (providing `operator<` that doesn't give a strict weak ordering) – M.M Jun 14 '19 at 01:41
  • @M.M actually it gives strict weak ordering, it just considers those 3 to be equal. – Slava Jun 14 '19 at 01:45
  • @Slava I see. I figured `map` constructor would reject equal keys , obviously not :) – M.M Jun 14 '19 at 01:53
  • Hi, I'm the author of nlohmann/json. If you have any suggestions how to improve the parse error messages, please consider open an issue at https://github.com/nlohmann/json/issues/new/choose so we can discuss this. – Niels Lohmann Jun 30 '19 at 11:08
  • @NielsLohmann thank you for finding this and responding! My issue was concerning a parser of my own that deserializes a well-formed JSON object into a custom data type. I'm not familiar with the built in parser's error messages, but I've had no problems with it. I understand the motivation for this comparison behavior. It simply defied my expectations in this innocent example. Perhaps a comment could be added to the documentation for [`json::type()`](https://nlohmann.github.io/json/classnlohmann_1_1basic__json_a2b2d781d7f2a4ee41bc0016e931cadf7.html) saying that `number_*` values compare equal – alter_igel Jul 02 '19 at 22:13

1 Answers1

7

You have this issue because there is operator< provided for this enum:

inline bool operator<(const value_t lhs, const value_t rhs) noexcept
{
    static constexpr std::array<std::uint8_t, 8> order = {{
            0 /* null */, 3 /* object */, 4 /* array */, 5 /* string */,
            1 /* boolean */, 2 /* integer */, 2 /* unsigned */, 2 /* float */
        }
    };

    const auto l_index = static_cast<std::size_t>(lhs);
    const auto r_index = static_cast<std::size_t>(rhs);
    return l_index < order.size() and r_index < order.size() and order[l_index] < order[r_index];
}

from here

And according to this code integer, unsigned and float are considered to be equal, hense your issue.

As a solution you can use your method or simply replace default comparator with lambda or provide specialization for std::less which is not using this operator.

Slava
  • 43,454
  • 1
  • 47
  • 90
  • The reason for this ordering is that the exact type of the number can be determined by the parser and is hence transparent to the user of the library. As such, I wanted all numbers to behave the same to avoid surprises down the road. – Niels Lohmann Jun 30 '19 at 11:10