Implement c++20 std::chrono::duration subsecond formatting - #2623
Conversation
…g to the c++20 standard
* Allow proper Duration::rep type to propagate via template argument deduction
* Allow proper Duration::rep type to propagate via template argument deduction
|
@vitaut Hi could you rerun the CI tests. Thanks |
|
@vitaut I locally used -Wconversion instead of -Wsign-converison so line of code still had to be fixed. If you could run the workflow one more time it would be much appreciated. Thanks |
| } | ||
| using nanoseconds_dbl = std::chrono::duration<double, std::nano>; | ||
| EXPECT_EQ(fmt::format("{:%S}", nanoseconds_dbl{-123456789.123456789}), | ||
| "-00.123456789"); |
There was a problem hiding this comment.
Why is the integral part 00 here?
There was a problem hiding this comment.
According to the spec:
If the number of seconds is less than 10, the result is prefixed with 0
The number of seconds is 0, which is less than 10, hence we write 00.
The MSVC implementation of std::format also interprets the standard this way. The check below passes locally in my instance of Visual Studio 2022
std::format("{:%S}", std::chrono::duration<double, std::nano{-123456789.123456789}) == std::string("-00.123456789")
There was a problem hiding this comment.
Oh, I misread the test case and thought that the integral part 123456789. is seconds rather than nanoseconds. I suggest changing the fractional part to something different to prevent confusion.
|
|
||
| TEST(chrono_test, cpp20_duration_subsecond_support) { | ||
| using attoseconds = std::chrono::duration<std::intmax_t, std::atto>; | ||
| // Check that 18 digits of subsecond precision are supported |
There was a problem hiding this comment.
nit: Please add punctuation (. in the end of the sentence) here and elsewhere.
| EXPECT_EQ(fmt::format("{:%H:%M:%S}", dur), formatted_dur); | ||
| } | ||
| // Check that durations with precision greater than std::chrono::seconds have | ||
| // fixed precision and empty zeros |
There was a problem hiding this comment.
"empty zeros"? I suggest rephrasing this as "zero fractional part" or smth like that.
| "40", | ||
| fmt::format("{:%S}", std::chrono::duration<double>(1e20)).substr(0, 2)); |
There was a problem hiding this comment.
According to the spec, the decimal point should be printed only if the duration "period" type is more precise than seconds:
If the precision of the input cannot be exactly represented with seconds, then the format is a decimal floating-point number with a fixed format and a precision matching that of the precision of the input
This can be determined statically at compile time and does not depend on runtime values of the duration. The test I modified has seconds precision, so I removed the decimal point.
In my local instance of Visual Studio 2022, the following test passes:
std::format("{:%S}", std::chrono::duration<double>{1.234}) == std::string("01")
There was a problem hiding this comment.
Shall we check that there is no decimal point then?
| } | ||
|
|
||
| void write(Rep value, int width) { | ||
| template <typename RepType> void write(RepType value, int width) { |
There was a problem hiding this comment.
Why introduce a template parameter instead of using Rep?
There was a problem hiding this comment.
Rep is currently assumed to be an unsigned type. I wanted the proper type to propagate through the code and be deduced in the template. Otherwise there could be an unsigned to signed conversion here which would lead to a -Wconversion error.
There was a problem hiding this comment.
Repis currently assumed to be an unsigned type.
I don't think this is correct. Rep is the representation type of a duration and it can be signed (in fact it's normally signed).
There was a problem hiding this comment.
Sorry, lowercase rep is used to store the internal duration value, and rep is unsigned to avoid overflow. At first I wanted to use the existing logic in the write() function to format a duration value of type lowercase rep. This is irrelevant now as I reverted this function to its previous state
| uint32_or_64_or_128_t<std::intmax_t> n = | ||
| to_unsigned(to_nonnegative_int(value, max_value<std::intmax_t>())); |
There was a problem hiding this comment.
Let's use long long instead of intmax_t because it's a built-in type and can also handle 18 digits of precision.
There was a problem hiding this comment.
As you wish, std::chrono::duration types use std::intmax_t internally that's why I went along with it.
| @@ -1319,20 +1319,20 @@ inline bool isfinite(T) { | |||
| } | |||
|
|
|||
| // Converts value to int and checks that it's in the range [0, upper). | |||
| 1000); | ||
| return std::chrono::duration<Rep, std::milli>(static_cast<Rep>(ms)); | ||
| } | ||
| template <class Duration> class subsecond_helper { |
There was a problem hiding this comment.
Having a utility struct seems unnecessary. I suggest converting it into a function (e.g. write_fractional_seconds) or merging the logic into write where it is used.
Co-authored-by: Victor Zverovich <victor.zverovich@gmail.com>
…ubsecond_helper class with a function
…th name write_fractional_seconds() * Revert write(Rep value, int width) function to previous state
…write_fractional_seconds()
|
@vitaut I have implemented the suggestions you have requested. If you have any further remarks I would gladly hear them. The workflows still need to be run :) |
| // No decimal point is printed so size() is 2. | ||
| EXPECT_EQ(value.size(), 2); | ||
| EXPECT_EQ("40", value.substr(0, 2)); |
There was a problem hiding this comment.
Let's simplify this:
EXPECT_EQ(value, "40");Then we don't need a comment because it's clear that we don't emit a decimal point.
There was a problem hiding this comment.
Of course. I recall the size of this string being platform dependent but after the changes in this PR it should always be consistent. Done
vitaut
left a comment
There was a problem hiding this comment.
A few more, mostly minor comments.
| template <typename Rep, typename Period, | ||
| FMT_ENABLE_IF(std::is_floating_point<Rep>::value)> | ||
| inline std::chrono::duration<Rep, std::milli> get_milliseconds( | ||
| // Returns the amount of digits according to the c++ 20 spec |
| // Returns the amount of digits according to the c++ 20 spec | ||
| // In the range [0, 18], if more than 18 fractional digits are required, | ||
| // then we return 6 for microseconds precision. | ||
| static constexpr int num_digits(long long num, long long den, int n = 0) { |
There was a problem hiding this comment.
I don't think static is needed here (and similarly in a functions below).
There was a problem hiding this comment.
Good point, static made sense when it was a member function, now it is no longer is needed.
| inline std::chrono::duration<Rep, std::milli> get_milliseconds( | ||
| // Returns the amount of digits according to the c++ 20 spec | ||
| // In the range [0, 18], if more than 18 fractional digits are required, | ||
| // then we return 6 for microseconds precision. |
There was a problem hiding this comment.
Is returning 6 actually required by the standard? Can we turn it into a compile-time error instead?
There was a problem hiding this comment.
From the standard:
...and a precision matching that of the precision of the input (or to a microseconds precision if the conversion to floating-point decimal seconds cannot be made within 18 fractional digits)
Microseconds precision is 6 digits. The MSVC implementation of std::format also behaves this way.
| } | ||
|
|
||
| template <class Duration> void write_fractional_seconds(Duration d) { | ||
| static constexpr auto fractional_width = |
There was a problem hiding this comment.
This doesn't need to be static because it's just an int.
There was a problem hiding this comment.
Good point, lightweight integral types probably do not need to be static, even if constexpr
* Remove static from detail functions, they are no longer member functions of a class and static is unnecessary. * Change comment from "amount" to "number"
|
|
||
| template <class Rep, class Period, | ||
| FMT_ENABLE_IF(!std::numeric_limits<Rep>::is_signed)> | ||
| static constexpr std::chrono::duration<Rep, Period> abs( |
There was a problem hiding this comment.
One more static to remove here.
There was a problem hiding this comment.
Removed in a follow-up commit.
|
Merged, thanks! |
|
A fuzzer has discovered a UB which seems to be introduced by this PR: fmt::print("{:%S}", std::chrono::duration<long double, std::ratio<1, 1000000> >(-1.7591538808701083784E+4865L)); |
|
Subseconds are always an integral value no longer than 18 digits. The implementation will cast all values to a long long. If the value is not representable by a long long like this huge double UBsan will report this error. It is not clear how to handle values not representable by intmax_t/long long, the spec doesn't seem to mention what to do. I guess it's up to the implementation how to truncate such values. |
|
I think we should add a range check when converting from an FP number. |
|
And throw an exception if the value is out of range? What method do you propose for range checking floats. I assume such checks exist already in the codebase |
|
I implemented a fix for the UB in 784e2a7. |
This commit does not work. I believe it can be safely reverted because currently the above example throws an exception "invalid value". If exceptions are disabled then we need an alternative approach.
Whereas if we change the type to
Also the commit addresses only |
|
Could you provide a PR with a better workaround for the UB? |
|
At first I thought of the following solution:
a. If yes, then cast it to its seconds representation, and truncate it. This way we don't subtract away the valuable subsecond part of the number. Something like this:
b. If not, then cast to But then I realized that in practice such code will never be reached because
Will thrown an exception if the value is larger than A possible workaround would be to avoid using Any thoughts? |
This sounds reasonable. We can always optimize this path later and correctness is more important. |
|
Ok, I am trying to format floating point subsecond values using the floating point formatter, but as always there are issues :). If the duration type is floating point, then we do our calculations using floating point arithmetic. Let's say we calculate the subseconds remaining to be
So we could cast to an integral type to remove the fractional part, but then we end up with the same problem that we started with, since some floating point values are not representable by integral types and we get UB. In other words, how can we remove the fractional part of floating point numbers without casting to an integral type? Or how can we format a FP number without its fractional part (and decimal point), using existing facilities? |
|
The easy way out would be to simply throw an exception if the formatted value is larger than |
|
I think we should format integral and fractional parts together. I took a stab at this as part of fixing another UB in d9f045f but improvements are welcome. |
|
I am a fan of this approach. We simply format the duration using the floating point formatting facility, otherwise we use the integral implementation. |
Hello,
I have returned with a solution to #2207, after dealing with licensing issues in PR #2554. This time I am 100% the author of the code. The solution is simplified, and could be adapted to use if-constexpr if C++17 is detected at compile-time. In fact, the solution I have proposed is working better than the current MSVC implementation which leads to template overflow when
std::attois used as a precision. I will open an issue there.std::intmax_t(the standard type for chrono tick counts) cause program termination withFMT_ASSERT(). The standard requires us to support up to 18 decimal digits, andstd::int64_tcan store a maximum value of 9,223,372,036,854,775,807 or, 9.2e+18,. Therefore it can represent all 18 digit numbers. However the following assert test had to be removed: