From e39bb284d453a524ba9e42e8e84bbccfaa5b966a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20H=C3=A4cker?= Date: Fri, 16 Oct 2026 09:06:04 +0200 Subject: [PATCH] Support parsing Add `tryparse` (and thereby `parse`) for `Fixed` and `Normed` types. The decimal string is rounded exactly to the nearest representable value, with ties to even, so the result never suffers from double rounding through an intermediate float. It works on fixed-width integers for raw types up to 64 bits, which makes it about twice as fast as converting from `Float64`, allocation-free, and compatible with `--trim`. --- README.md | 6 ++ src/FixedPointNumbers.jl | 118 +++++++++++++++++++++++++++++++++++++++ test/common.jl | 34 +++++++++++ test/fixed.jl | 75 ++++++++++++++++++++++++- test/normed.jl | 45 +++++++++++++++ 5 files changed, 277 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 35f9e202..13aa2fb5 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,12 @@ To construct such a number, use `1.3N4f12`, `N4f12(1.3)`, `convert(N4f12, 1.3)`, `Normed{UInt16,12}(1.3)`, or `reinterpret(N4f12, 0x14cc)`. The last syntax means to construct a `N4f12` from the `UInt16` value `0x14cc`. +To read a number from its textual representation, use `parse(N4f12, "1.3")`, or +`tryparse(N4f12, "1.3")` which returns `nothing` instead of throwing when the +string is malformed or out of range. +The string must hold a decimal number, optionally with an exponent such as `"13e-1"`, +and is rounded exactly to the nearest representable value, with ties to even. + More generally, an arbitrary number of bits from any of the standard unsigned integer widths can be used for the fractional part. For example: `Normed{UInt32,16}`, `Normed{UInt64,3}`, `Normed{UInt128,7}`. diff --git a/src/FixedPointNumbers.jl b/src/FixedPointNumbers.jl index 33e25b5a..2edca1c8 100644 --- a/src/FixedPointNumbers.jl +++ b/src/FixedPointNumbers.jl @@ -97,6 +97,124 @@ function rationalize(::Type{Ti}, x::FixedPoint; tol::Real=eps(x)) where Ti <: In tol <= eps(x) ? Rational{Ti}(x) : rationalize(Ti, float(x), tol) end +# parsing + +_isdigit(c) = UInt8('0') <= c <= UInt8('9') +_isspace(c) = (c == UInt8(' ')) | (UInt8('\t') <= c <= UInt8('\r')) + +# unsigned type that holds ten times the scale of `X` without overflow +_parsetype(::Type{X}) where {T, X <: FixedPoint{T}} = + bitwidth(T) <= 32 ? UInt64 : bitwidth(T) <= 64 ? UInt128 : BigInt + +# the parser compares code units with ASCII bytes, which needs UTF-8 +Base.tryparse(::Type{X}, s::AbstractString) where {X <: FixedPoint} = tryparse(X, String(s)) + +function Base.tryparse(::Type{X}, s::Union{String, SubString{String}}) where {X <: FixedPoint} + cu = codeunits(s) + lo, hi = 1, length(cu) + # skip leading whitespace + while lo <= hi && _isspace(cu[lo]) + lo += 1 + end + lo <= hi || return nothing + # skip trailing whitespace, stopping at the latest at `lo`, which holds no space + while _isspace(cu[hi]) + hi -= 1 + end + neg = cu[lo] == UInt8('-') + if neg || cu[lo] == UInt8('+') + lo += 1 + end + i = lo + # digits before the dot + point = 0 + hasdot = false + # scan the digits and at most one dot + while i <= hi + c = cu[i] + if _isdigit(c) + point += !hasdot + elseif c == UInt8('.') && !hasdot + hasdot = true + else + break + end + i += 1 + end + # the loop consumed at most one dot, so it consumed a digit if it consumed more + i - lo > hasdot || return nothing + mantissa_end = i - 1 + exponent = zero(widen(Int)) + if i <= hi + (cu[i] == UInt8('e')) | (cu[i] == UInt8('E')) || return nothing + i += 1 + i <= hi || return nothing + negexp = cu[i] == UInt8('-') + if negexp || cu[i] == UInt8('+') + i += 1 + i <= hi || return nothing + end + # accumulate the exponent digits + while i <= hi + _isdigit(cu[i]) || return nothing + # with at most `typemax(Int)` digits, nonzero values beyond the cap overflow or round to 0 + exponent = min(10exponent + (cu[i] - UInt8('0')), 2 * widen(typemax(Int))) + i += 1 + end + negexp && (exponent = -exponent) + end + return _tryparse_decimal(X, cu, lo, mantissa_end, point + exponent, neg) +end + +# Round the decimal digits in `cu[lo:hi]` (ignoring the dot), of which the first `point` +# form the integer part, exactly and with ties to even. +function _tryparse_decimal(::Type{X}, cu::Base.CodeUnits{UInt8}, lo::Int, hi::Int, point::Integer, neg::Bool) where {T, f, X <: FixedPoint{T,f}} + U = _parsetype(X) + # the raw value is the parsed value times the scale `S`, rounded + S = X <: Fixed ? one(U) << f : (one(U) << f) - one(U) + qmax = X <: Fixed && neg ? U(typemax(T)) + one(U) : U(typemax(T)) + imax = qmax ÷ S + one(U) + ip, k = zero(U), 0 + i = lo + while i <= hi && k < point + c = cu[i] + if c != UInt8('.') + ip = 10ip + (c - UInt8('0')) + ip > imax && return nothing + k += 1 + end + i += 1 + end + for _ in k+1:point + iszero(ip) && break + ip *= 10 + ip > imax && return nothing + end + # Horner's scheme from the last digit yields `S * fraction == fp + (r + rest) / 10` + # with an integer `fp`, a digit `r` and `0 <= rest < 1`, where `sticky == (rest > 0)`. + fp, r, sticky = zero(U), zero(U), false + for j in hi:-1:i + cu[j] == UInt8('.') && continue + sticky |= !iszero(r) + fp, r = divrem((cu[j] - UInt8('0')) * S + fp, U(10)) + end + for _ in 1:-point + sticky |= !iszero(r) + fp, r = divrem(fp, U(10)) + iszero(fp) & iszero(r) && break + end + q = ip * S + fp + inexact = !iszero(r) | sticky + if X <: Normed + neg && (!iszero(q) | inexact) && return nothing + (q < qmax) | ((q == qmax) & !inexact) || return nothing + end + q += (r > 5) | ((r == 5) & (sticky | isodd(q))) + q <= qmax || return nothing + raw = q % T + return reinterpret(X, neg ? -raw : raw) +end + """ isapprox(x::FixedPoint, y::FixedPoint; rtol=0, atol=max(eps(x), eps(y))) diff --git a/test/common.jl b/test/common.jl index 3a046080..87fad13f 100644 --- a/test/common.jl +++ b/test/common.jl @@ -161,6 +161,40 @@ function test_rem_type(TX::Type) end end +# `n / 10^d` written out in decimal notation +function decimal_string(n::BigInt, d::Int) + s = lpad(string(abs(n)), d + 1, '0') + string(n < 0 ? "-" : "", s[1:end-d], d == 0 ? "" : ".", s[end-d+1:end]) +end + +# exact conversion of `v`, rounding ties to even +function parse_reference(::Type{X}, v::Rational{BigInt}) where {T, f, X <: FixedPoint{T,f}} + if X <: Fixed + y = round(BigInt, v * big(2)^f) + typemin(T) <= y <= typemax(T) || return nothing + else + y = v * (big(2)^f - 1) + 0 <= y <= typemax(T) || return nothing + y = round(BigInt, y) + end + return reinterpret(X, y % T) +end + +function test_parse(TX::Type) + @testset "parse $X" for X in target(TX; ex = :light) + T, f = rawtype(X), nbitsfrac(X) + S = X <: Fixed ? big(2)^f : big(2)^f - 1 + rng = StableRNG(1234) + raws = (typemin(T), typemax(T), zero(T), one(T), rand(rng, T, 10)...) + # decimals next to a representable value (h == 0) or a midpoint, which + # is exact for `Fixed` with `d == f + 1` + @test all(Iterators.product(raws, -1:1, (0, 3, 20, f + 1), -1:1)) do (raw, h, d, δ) + n = round(BigInt, (2big(raw) + h) * big(10)^d // 2S) + δ + tryparse(X, decimal_string(n, d)) === parse_reference(X, n // big(10)^d) + end + end +end + function test_rem_nan(TX::Type) @testset "nan % $X" for X in target(TX, :i8, :i16, :i32, :i64; ex = :thin) @test NaN % X === NaN32 % X === NaN16 % X === zero(X) diff --git a/test/fixed.jl b/test/fixed.jl index 509fa4a8..63a9834d 100644 --- a/test/fixed.jl +++ b/test/fixed.jl @@ -86,7 +86,11 @@ end @testset "domain of f" begin # TODO: change the upper limit - @test_logs (:warn, r"`f=8` with raw type `T=Int8` will be removed") zero(Fixed{Int8,8}) + if Base.JLOptions().depwarn == 1 + @test_logs (:warn, r"`f=8` with raw type `T=Int8` will be removed") zero(Fixed{Int8,8}) + else + @test zero(Fixed{Int8,8}) isa Fixed{Int8,8} + end @test_throws DomainError zero(Fixed{Int8,-1}) # @test_throws DomainError zero(Fixed{Int8,8}) @test_throws DomainError zero(Fixed{Int8,9}) @@ -204,6 +208,75 @@ end @test convert(Q0f63, tp) === reinterpret(Q0f63, typemax(Int64)) end +@testset "parse" begin + @test parse(Q3f4, "7.92") === 7.9375Q3f4 + @test parse(Q0f7, "-1.0") === -1.0Q0f7 + @test tryparse(Q3f4, "7.92") === 7.9375Q3f4 + @test tryparse(Q3f4, "-3.5") === -3.5Q3f4 + @test tryparse(Q3f4, "999.0") === nothing + @test tryparse(Q3f4, "abc") === nothing + @test_throws ArgumentError parse(Q3f4, "abc") + @test_throws ArgumentError parse(Q3f4, "999.0") + + # leading/trailing whitespace and exponent notation + @test tryparse(Q3f4, " 1.5 ") === 1.5Q3f4 + @test tryparse(Q3f4, "1e0") === 1.0Q3f4 + @test tryparse(Q3f4, "1.0e0") === 1.0Q3f4 + # signs, bare dot positions and malformed input + @test tryparse(Q3f4, "+1.5") === 1.5Q3f4 + @test tryparse(Q3f4, ".5") === 0.5Q3f4 + @test tryparse(Q3f4, "1.") === 1.0Q3f4 + @test tryparse(Q3f4, "-.5") === -0.5Q3f4 + @test tryparse(Q3f4, "") === nothing + @test tryparse(Q3f4, ".") === nothing + @test tryparse(Q3f4, "1.2.3") === nothing + @test tryparse(Q3f4, "\t1.5\r\n") === 1.5Q3f4 + @test tryparse(Q3f4, SubString("x1.5", 2)) === 1.5Q3f4 + @test tryparse(Q3f4, Test.GenericString("-1.5e0")) === -1.5Q3f4 + @test tryparse(Q3f4, "1 .5") === nothing + @test tryparse(Q3f4, "+-1") === nothing + @test tryparse(Q3f4, "1_0") === nothing + @test tryparse(Q3f4, "1e") === nothing + @test tryparse(Q3f4, "1e+") === nothing + @test tryparse(Q3f4, "e1") === nothing + @test tryparse(Q3f4, "0x1p-1") === nothing + @test tryparse(Q3f4, "Inf") === nothing + @test tryparse(Q3f4, "NaN") === nothing + # exponents, of which only the sign matters beyond the `Int` range + @test tryparse(Q3f4, "25E-1") === 2.5Q3f4 + @test tryparse(Q3f4, ".0625e+2") === 6.25Q3f4 + @test tryparse(Q3f4, "1e1") === nothing + @test tryparse(Q3f4, "0e$(typemax(Int))") === 0Q3f4 + @test tryparse(Q3f4, "0e99999999999999999999") === 0Q3f4 + @test tryparse(Q3f4, "1e99999999999999999999") === nothing + @test tryparse(Q3f4, "-1e-$(typemax(Int))") === 0Q3f4 + @test tryparse(Q3f4, "-1e-99999999999999999999") === 0Q3f4 + # zeros before the first nonzero digit, before and after the dot + @test tryparse(Q3f4, "00000000000000000000000000001.5") === 1.5Q3f4 + @test tryparse(Q3f4, "0." * "0"^27 * "15e28") === 1.5Q3f4 + @test tryparse(Q3f4, "0") === 0Q3f4 + @test tryparse(Q3f4, "00.") === 0Q3f4 + @test tryparse(Q3f4, ".00") === 0Q3f4 + @test tryparse(Q3f4, "-0.0e1") === 0Q3f4 + @test tryparse(Q3f4, "0..5") === nothing + @test tryparse(Q3f4, ".e1") === nothing + # ties go to even, the bounds are typemin - eps/2 inclusive and typemax + eps/2 exclusive + @test tryparse(Q3f4, "0.03125") === 0Q3f4 + @test tryparse(Q3f4, "0.09375") === 0.125Q3f4 + @test tryparse(Q3f4, "0.03125000000000000000000000001") === eps(Q3f4) + @test tryparse(Q3f4, "7.96874999999999999999999999999") === typemax(Q3f4) + @test tryparse(Q3f4, "7.96875") === nothing + @test tryparse(Q3f4, "-8.03125") === typemin(Q3f4) + @test tryparse(Q3f4, "-8.03125000000000000000000000001") === nothing + # rounding through `Float64` would round to the midpoint first, and then to even + @test tryparse(Q0f7, "0.00390625000000000000001") === eps(Q0f7) + @test tryparse(Fixed{Int128,64}, "0.5") === reinterpret(Fixed{Int128,64}, Int128(1) << 63) + @test tryparse(Q3f4, "0.50000000000000000000") === 0.5Q3f4 + @test tryparse(Q3f4, "0.50000000000000000001") === 0.5Q3f4 + + test_parse(Fixed) +end + @testset "bool conversions" begin @test convert(Bool, 0.0Q1f6) === false @test convert(Bool, 1.0Q1f6) === true diff --git a/test/normed.jl b/test/normed.jl index 58bc4eb5..f6b2f703 100644 --- a/test/normed.jl +++ b/test/normed.jl @@ -174,6 +174,51 @@ end @test_throws InexactError convert(Int8, 256N8f8) end +@testset "parse" begin + @test parse(N0f8, "0.5") === N0f8(0.5) + @test parse(N0f8, "1.0") === 1N0f8 + @test parse(N0f8, "0.0") === 0N0f8 + @test tryparse(N0f8, "0.5") === N0f8(0.5) + @test tryparse(N0f8, "-0.1") === nothing + @test tryparse(N0f8, "1.5") === nothing + @test tryparse(N0f8, "abc") === nothing + @test_throws ArgumentError parse(N0f8, "-0.1") + @test_throws ArgumentError parse(N0f8, "abc") + + # leading/trailing whitespace and exponent notation + @test tryparse(N0f8, " 0.5 ") === N0f8(0.5) + @test tryparse(N0f8, "1e0") === 1N0f8 + @test tryparse(N0f8, "0.5e0") === N0f8(0.5) + # signs, bare dot positions and malformed input + @test tryparse(N0f8, "+0.5") === N0f8(0.5) + @test tryparse(N0f8, ".5") === N0f8(0.5) + @test tryparse(N0f8, "-1") === nothing + @test tryparse(N0f8, "") === nothing + @test tryparse(N0f8, ".") === nothing + @test tryparse(N0f8, "0.5.0") === nothing + @test tryparse(N0f8, "0x1p-1") === nothing + # the value must lie in range exactly, but negative zero is fine + @test tryparse(N0f8, "-0.0") === 0N0f8 + @test tryparse(N0f8, "-0e-$(typemax(Int))") === 0N0f8 + @test tryparse(N0f8, "-0e99999999999999999999") === 0N0f8 + @test tryparse(N0f8, "-0.001") === nothing + @test tryparse(N0f8, "-1e-$(typemax(Int))") === nothing + @test tryparse(N0f8, "-1e-99999999999999999999") === nothing + @test tryparse(N0f8, "1e-99999999999999999999") === 0N0f8 + @test tryparse(N0f8, "1.000000000000000000000000000001") === nothing + @test tryparse(N0f8, "0.999999999999999999999999999999") === 1N0f8 + @test tryparse(N0f8, "1e99999999999999999999") === nothing + # ties go to even + @test tryparse(N0f8, "0.5") === reinterpret(N0f8, 0x80) + @test tryparse(N7f1, "0.25") === 0N7f1 + @test tryparse(N7f1, "0.75") === 1N7f1 + @test tryparse(N7f1, "255") === typemax(N7f1) + @test tryparse(N7f1, "255.25") === nothing + @test tryparse(Normed{UInt128,128}, "0.5") === reinterpret(Normed{UInt128,128}, typemax(UInt128) ÷ 2 + 1) + + test_parse(Normed) +end + @testset "rational conversions" begin @testset "$N to/from rational" for N in target(Normed) @test convert(Rational, oneunit(N)) == 1//1