diff --git a/README.md b/README.md index 35f9e20..13aa2fb 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 33e25b5..2edca1c 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 3a04608..87fad13 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 509fa4a..63a9834 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 58bc4eb..f6b2f70 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