Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions docs/mkdocs/docs/api/basic_json/std_formatter.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ Specialization to make JSON values formattable with [`std::format`](https://en.c
(and the other members of C++20's `<format>` header, such as `std::format_to`).

A subset of the [standard format spec grammar](https://en.cppreference.com/w/cpp/utility/format/spec) is
supported, repurposed for JSON pretty-printing; any other spec component (sign, the `0` flag, precision,
`L`, a dynamic width such as `#!cpp "{:{}}"`, or a trailing type character) throws
supported, repurposed for JSON pretty-printing and number formatting. Any other spec component (sign, the
`0` flag, `L`, a dynamic width or precision such as `#!cpp "{:{}}"`, or a trailing type character) throws
[`std::format_error`](https://en.cppreference.com/w/cpp/utility/format/format_error):

- `#!cpp "{}"` serializes the value the same way as [`dump()`](dump.md) (compact, no whitespace).
Expand All @@ -25,10 +25,34 @@ supported, repurposed for JSON pretty-printing; any other spec component (sign,
way as `#!cpp dump(indent, indent_char)`. The alignment direction itself (`#!cpp '<'`, `#!cpp '>'`,
`#!cpp '^'`) has no separate meaning for JSON values — only the fill character before it is used, and
any of the three directions is accepted.
- A precision (e.g. `#!cpp "{:.3}"`) writes floating-point numbers with that many significant digits,
exactly as `#!cpp std::format("{:.3}", x)` writes a floating-point number `x` (and as
`#!c printf("%.3g", x)` does). For example, π becomes `#!json 3.14`, `#!json 1.9999` becomes `#!json 2.0`,
and `#!json 12345.678` becomes `#!json 1.23e+04`. A number that would otherwise read back as an integer
gets a `.0`. Integers are always written exactly, and NaN and infinity are still written as
`#!json null`. It combines with the specs above, e.g. `#!cpp "{:#.3}"`.

This specialization is only available for `#!cpp char`-based JSON values and only if the standard library
provides `<format>`, controlled by the [`JSON_HAS_STD_FORMAT`](../macros/json_has_std_format.md) macro.

!!! warning "Precision and round-tripping"

Without a precision, floating-point numbers are written with the shortest representation that parses back to
the same value. With a precision, this guarantee is lost: `#!cpp json::parse(std::format("{:.3}", j))` can
differ from `j`.

!!! note "Rounding"

The digits are rounded from the exact binary value. `#!cpp 2.675` is stored as
2.67499999999999982236431605997495353221893310546875, so `#!cpp std::format("{:.3}", json(2.675))` gives
`#!json 2.67`, the same as `#!cpp std::format("{:.3}", 2.675)`, even though [`dump()`](dump.md) writes
`#!json 2.675`.

!!! hint "Streams"

There is no stream manipulator for the precision, but a formatted value can be written to a stream:
`#!cpp os << std::format("{:.3}", j)`.

## Examples

??? example
Expand Down
6 changes: 5 additions & 1 deletion docs/mkdocs/docs/examples/std_formatter.c++20.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,9 @@ int main()
std::cout << std::format("{:2}", j) << "\n\n";

// fill-and-align sets the indent character, like dump(4, '.')
std::cout << std::format("{:.>#}", j) << std::endl;
std::cout << std::format("{:.>#}", j) << "\n\n";

// a precision sets the significant digits of floating-point numbers
json k = {{"pi", 3.141592653589793}, {"answer", 42}};
std::cout << std::format("{:.3}", k) << std::endl;
}
2 changes: 2 additions & 0 deletions docs/mkdocs/docs/examples/std_formatter.c++20.output
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@
...."one": 1,
...."two": 2
}

{"answer":42,"pi":3.14}
4 changes: 3 additions & 1 deletion docs/mkdocs/docs/features/serialization.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,9 @@ std::println("{:#}", j); // pretty-printed with the default indent
```

The format spec mirrors the `dump` parameters: `#!cpp "{:#}"` pretty-prints, a width such as `#!cpp "{:2}"` sets the
indent, and a fill-and-align prefix such as `#!cpp "{:.>#}"` sets the indent character.
indent, and a fill-and-align prefix such as `#!cpp "{:.>#}"` sets the indent character. A precision such as
`#!cpp "{:.3}"` writes floating-point numbers with that many significant digits, like `std::format` writes a number,
which gives up round-tripping; see [number serialization](types/number_handling.md#number-serialization).

For the [{fmt}](https://github.com/fmtlib/fmt) library, the library ships a
[`format_as`](../api/basic_json/format_as.md) helper. Note its behavior depends on the `fmt` version; see the
Expand Down
4 changes: 4 additions & 0 deletions docs/mkdocs/docs/features/types/number_handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ That is, `-0` is stored as a signed integer, but the serialization does not repr
- Floating-point numbers are serialized as specified by the `#!c %g` printf modifier with
[`std::numeric_limits<double>::max_digits10`](https://en.cppreference.com/w/cpp/types/numeric_limits/max_digits10)
significant digits. The rationale is to use the shortest representation while still allowing round-tripping.
- A precision can be chosen with [`std::format`](../../api/basic_json/std_formatter.md), e.g.
`#!cpp std::format("{:.3}", j)`. Floating-point numbers are then written with that many significant digits, with
the digits of `#!c printf("%.*g")` and `std::format`: π becomes `#!json 3.14` and `#!json 1.9999` becomes
`#!json 2.0`. The output no longer round-trips. Integers are always written exactly.

!!! hint "Notes regarding precision of floating-point numbers"

Expand Down
5 changes: 5 additions & 0 deletions docs/mkdocs/docs/home/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,11 @@ The `serializer` (used by [`dump`](../api/basic_json/dump.md) and [`operator<<`]
- `output_stream_adapter` writes to a `std::ostream`.
- `output_string_adapter` appends to a string.

The `serializer` writes IEEE-754 `float` and `double` numbers with the Grisu2 algorithm in `to_chars.hpp`, and other
floating-point types with `snprintf("%.*g")` and `max_digits10` digits. With a precision (used by the
[`std::format`](../api/basic_json/std_formatter.md) spec `{:.N}`), every floating-point type takes the `snprintf`
path, with N digits instead of `max_digits10`.

## Value conversion

Values are converted from and to other types with the `JSONSerializer` template parameter. The default,
Expand Down
100 changes: 88 additions & 12 deletions include/nlohmann/detail/output/serializer.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ class serializer
result consists of ASCII characters only.
@param[in] indent_step_ the indent level
@param[in] error_handler_ how to react on decoding errors
@param[in] float_precision_ number of significant digits for
floating-point numbers, as for std::format("{:.N}"); a negative value (the
default) writes the shortest representation that round-trips

None of @a pretty_print_, @a ensure_ascii_ and @a indent_step_ change over
the life of the serializer, so they are captured once here instead of
Expand All @@ -81,14 +84,16 @@ class serializer
const bool pretty_print_ = false,
const bool ensure_ascii_ = false,
const std::size_t indent_step_ = 0,
error_handler_t error_handler_ = error_handler_t::strict)
error_handler_t error_handler_ = error_handler_t::strict,
const int float_precision_ = -1)
: o(&s)
, locale(std::localeconv())
, indent_char(ichar)
, pretty_print(pretty_print_)
, ensure_ascii(ensure_ascii_)
, indent_step(indent_step_)
, error_handler(error_handler_)
, float_precision(float_precision_)
{}

// deleted because of pointer members
Expand Down Expand Up @@ -1507,6 +1512,12 @@ class serializer
return;
}

if (JSON_HEDLEY_UNLIKELY(float_precision >= 0))
{
dump_float_with_precision(x);
return;
}

// If number_float_t is an IEEE-754 single or double precision number,
// use the Grisu2 algorithm to produce short numbers which are
// guaranteed to round-trip, using strtof and strtod, resp.
Expand Down Expand Up @@ -1544,42 +1555,103 @@ class serializer
void dump_float(number_float_t x, std::false_type /*is_ieee_single_or_double*/)
{
// get the number of digits for a float -> text -> float round-trip
static constexpr auto d = std::numeric_limits<number_float_t>::max_digits10;
dump_float_snprintf(number_buffer, std::numeric_limits<number_float_t>::max_digits10, x, false);
}

/// the largest float precision; it exceeds the significant digits of any
/// double (at most 767), so only very long long doubles are cut short
static constexpr int max_float_precision = 1000;

/*!
@brief dump a floating-point number with @ref float_precision significant digits

This is the same snprintf("%.*g") path as for types that are not IEEE-754
float or double, only with the given number of digits, so the digits are
those of printf and std::format("{:.N}", x).
*/
// not inlined, so that its large buffer does not grow the stack frame of
// the default path
JSON_HEDLEY_NEVER_INLINE
void dump_float_with_precision(number_float_t x)
{
// precision + sign, decimal point, "0.000" prefix, and exponent
std::array < char, max_float_precision + 32 > buffer{{}};
// no std::min: it would odr-use max_float_precision, which C++11 does not define
const int d = float_precision < max_float_precision ? float_precision : max_float_precision;
dump_float_snprintf(buffer, d, x, true);
}

/*!
@brief dump a floating-point number with @a d significant digits via snprintf

The output is made locale-independent, and ".0" is appended to values that
would otherwise read back as integers.

With @a trim_zeros, trailing zeros of the fraction are removed, as "%g"
requires anyway. macOS's printf keeps them on exact ties in exponent
notation, e.g. "%.3g" of 5405000 gives "5.40e+06" instead of "5.4e+06",
which std::format does not. It is only set for an explicit precision, so
the default output is unchanged.
*/
template<std::size_t N>
void dump_float_snprintf(std::array<char, N>& buffer, const int d, number_float_t x, const bool trim_zeros)
{
// the actual conversion
std::ptrdiff_t len = snprintf_float(number_buffer.data(), number_buffer.size(), d, x);
std::ptrdiff_t len = snprintf_float(buffer.data(), buffer.size(), d, x);

// negative value indicates an error
JSON_ASSERT(len > 0);
// check if the buffer was large enough
JSON_ASSERT(static_cast<std::size_t>(len) < number_buffer.size());
JSON_ASSERT(static_cast<std::size_t>(len) < buffer.size());

// erase thousands separators
if (locale.thousands_sep != '\0')
{
// NOLINTNEXTLINE(readability-qualified-auto,llvm-qualified-auto): std::remove returns an iterator, see https://github.com/nlohmann/json/issues/3081
const auto end = std::remove(number_buffer.begin(), number_buffer.begin() + len, locale.thousands_sep);
std::fill(end, number_buffer.end(), '\0');
JSON_ASSERT((end - number_buffer.begin()) <= len);
len = (end - number_buffer.begin());
const auto end = std::remove(buffer.begin(), buffer.begin() + len, locale.thousands_sep);
std::fill(end, buffer.end(), '\0');
JSON_ASSERT((end - buffer.begin()) <= len);
len = (end - buffer.begin());
}

// convert decimal point to '.'
if (locale.decimal_point != '\0' && locale.decimal_point != '.')
{
// NOLINTNEXTLINE(readability-qualified-auto,llvm-qualified-auto): std::find returns an iterator, see https://github.com/nlohmann/json/issues/3081
const auto dec_pos = std::find(number_buffer.begin(), number_buffer.end(), locale.decimal_point);
if (dec_pos != number_buffer.end())
const auto dec_pos = std::find(buffer.begin(), buffer.end(), locale.decimal_point);
if (dec_pos != buffer.end())
{
*dec_pos = '.';
}
}

put_buffer(number_buffer, static_cast<std::size_t>(len));
if (trim_zeros)
{
char* const begin = buffer.data();
char* const end = begin + len;
const char* const dot = std::find(begin, end, '.');
if (dot != end)
{
char* const exp = std::find(begin, end, 'e');
char* last = exp;
while (*(last - 1) == '0')
{
--last;
}
if (last - 1 == dot)
{
--last;
}
// move the exponent (and the terminating '\0') behind the digits
len = (std::copy(exp, end + 1, last) - 1) - begin;
}
}

put_buffer(buffer, static_cast<std::size_t>(len));

// determine if we need to append ".0"
const bool value_is_int_like =
std::none_of(number_buffer.begin(), number_buffer.begin() + len + 1,
std::none_of(buffer.begin(), buffer.begin() + len + 1,
[](char c)
{
return c == '.' || c == 'e';
Expand Down Expand Up @@ -1659,6 +1731,10 @@ class serializer
/// error_handler how to react on decoding errors
const error_handler_t error_handler;

/// significant digits for floating-point numbers; negative for the
/// shortest round-trip representation
const int float_precision;

/// buffer collecting output before it is flushed to the output adapter, so
/// that the many small structural writes become few bulk writes
static constexpr std::size_t write_buffer_size = 1024;
Expand Down
41 changes: 39 additions & 2 deletions include/nlohmann/json.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -6537,6 +6537,10 @@ struct formatter<nlohmann::NLOHMANN_BASIC_JSON_TPL, char> // NOLINT(cert-dcl58-c
// output with that many spaces (or indent_char) per level (dump(indent, indent_char)).
int indent = -1;
char indent_char = ' ';
// -1 means the shortest round-trip representation for floating-point
// numbers; any value >= 0 is a number of significant digits, as for
// std::format("{:.N}") on a floating-point number
int precision = -1;

constexpr auto parse(format_parse_context& ctx) -> format_parse_context::iterator
{
Expand Down Expand Up @@ -6580,7 +6584,26 @@ struct formatter<nlohmann::NLOHMANN_BASIC_JSON_TPL, char> // NOLINT(cert-dcl58-c
}
}

// sign, the '0' flag, precision, locale-specific formatting ('L'), dynamic
// ['.' precision] - the number of significant digits for floating-point
// numbers, e.g. "{:.3}" or "{:#.3}"; integers are always written exactly.
// The value is saturated rather than overflowing, at the serializer's
// maximum of 1000, which covers every significant digit of a double.
if (it != end && *it == '.')
{
++it;
if (it == end || *it < '0' || *it > '9')
{
JSON_THROW(format_error("missing precision for nlohmann::json"));
}
precision = 0;
while (it != end && *it >= '0' && *it <= '9')
{
precision = (std::min)((precision * 10) + (*it - '0'), 1000);
++it;
}
}

// sign, the '0' flag, locale-specific formatting ('L'), dynamic
// width/precision ("{...}"), and type characters all have no meaning for
// JSON values; none of them are consumed above, so they all end up rejected
// by this single check along with any other unrecognized trailing spec.
Expand All @@ -6595,9 +6618,23 @@ struct formatter<nlohmann::NLOHMANN_BASIC_JSON_TPL, char> // NOLINT(cert-dcl58-c
template<typename FormatContext>
auto format(const nlohmann::NLOHMANN_BASIC_JSON_TPL& j, FormatContext& ctx) const -> decltype(ctx.out())
{
using json_t = nlohmann::NLOHMANN_BASIC_JSON_TPL;

// dump()'s own default (indent = -1) already means compact output, so this
// covers both the compact and pretty-printed cases without a branch.
const auto dumped = j.dump(indent, indent_char);
if (precision < 0)
{
const auto dumped = j.dump(indent, indent_char);
return std::copy(dumped.begin(), dumped.end(), ctx.out());
}

// dump() has no precision parameter, so the serializer is used directly
typename json_t::string_t dumped;
::nlohmann::detail::output_string_adapter<char, typename json_t::string_t> adapter(dumped);
::nlohmann::detail::serializer<json_t> s(adapter, indent_char, indent >= 0, false,
indent >= 0 ? static_cast<std::size_t>(indent) : 0,
::nlohmann::detail::error_handler_t::strict, precision);
s.dump(j);
return std::copy(dumped.begin(), dumped.end(), ctx.out());
}
};
Expand Down
Loading
Loading