diff --git a/README.md b/README.md index e325636..964ec20 100644 --- a/README.md +++ b/README.md @@ -13,71 +13,94 @@ Why is this needed? The following code in Julia gives an answer In words, the binary floating point arithmetics implemented in computers has finite resolution - not all real numbers (even within the limits) can be expressed exactly. While many scientific and engineering fields can handle this behavior, it is not acceptable in fields like finance, where it's important to be able to trust that $0.30 is actually 30 cents, rather than 30.000000000000004 cents. ## Installation + ```julia julia> Pkg.add("Decimals") ``` or just `Ctrl`+`]` and - ```julia (v1.2) pkg> add Decimals ``` + ## Usage + ```julia julia> using Decimals ``` -### Creating the `Decimal` object -You can parse `Decimal` objects from strings: +### Construction + +`Decimal` is constructed by passing values `s`, `c`, `q` such that +`x = (-1)^s * c * 10^q`: ```julia -julia> parse(Decimal, "0.2") -Decimal(0,2,-1) +julia> Decimal(0, 1, -1) +0.1 -julia> parse(Decimal, "-2.5e6") -Decimal(1,25,5) +julia> Decimal(1, 1, -1) +-0.1 ``` -You can also construct `Decimal` objects from real numbers in Julia: -```julia -julia> Decimal(0.1) -Decimal(0,1,-1) -julia> Decimal(-1003) -Decimal(1, 1003, 0) -``` +### Parsing from string -Or can create `Decimal` objects from either strings or numbers using `decimal`: +You can parse `Decimal` objects from strings: ```julia -julia> decimal("0.2") -Decimal(0,2,-1) +julia> x = "0.2" +"0.2" -julia> decimal(0.1) -Decimal(0,1,-1) +julia> parse(Decimal, x) +0.2 -julia> decimal("-2.5e6") -Decimal(1,25,5) +julia> tryparse(Decimal, x) +0.2 ``` +Parsing support scientific notation. -To convert back to a string or a number (float in this case): +### Conversion +Any real number can be converted to a `Decimal`: ```julia -julia> x = decimal("0.2"); +julia> Decimal(0.2) +0.2 -julia> string(x) -"0.2" +julia> Decimal(-10) +-10 +``` -julia> number(x) +A `Decimal` can be converted to numeric types that can represent it: +```julia +julia> Float64(Decimal(0.2)) 0.2 + +julia> Int(Decimal(10)) +10 + +julia> Float64(Decimal(0, 1, 512)) +ERROR: ArgumentError: cannot parse "100[...]" as Float64 + +julia> Int(Decimal(0.4)) +ERROR: ArgumentError: invalid base 10 digit '.' in "0.4" ``` -It is also possible to call the Decimal constructor directly, by specifying the sign (`s`), coefficient (`c`), and exponent (`q`): +### String representation +A string in the decimal form of a `Decimal` can be obtained via +`string(::Decimal)`: ```julia -julia> Decimal(1,2,-2) +julia> string(Decimal(0.2)) +"0.2" ``` -The numerical value of a Decimal is given by `(-1)^s * c * 10^q`. `s` must be 0 (positive) or 1 (negative); `c` must be non-negative; `c` and `q` must be integers. +The 2- and 3-args methods for `show` are implemented: +```julia +julia> repr(Decimal(1000000.0)) +"Decimal(0, 10, 5)" + +julia> repr("text/plain", Decimal(1000000.0)) +"1.0E+6" +``` ### Operations ```julia diff --git a/src/Decimals.jl b/src/Decimals.jl index ad02568..a04ff02 100644 --- a/src/Decimals.jl +++ b/src/Decimals.jl @@ -5,7 +5,6 @@ module Decimals export Decimal, - decimal, number, normalize @@ -39,4 +38,8 @@ include("round.jl") include("hash.jl") +include("parse.jl") + +include("show.jl") + end diff --git a/src/decimal.jl b/src/decimal.jl index 1f0029f..4408d85 100644 --- a/src/decimal.jl +++ b/src/decimal.jl @@ -1,64 +1,17 @@ -# Convert a string to a decimal, e.g. "0.01" -> Decimal(0, 1, -2) -function Base.parse(::Type{Decimal}, str::AbstractString) - if 'e' ∈ str - return parse(Decimal, scinote(str)) - elseif 'E' ∈ str - return parse(Decimal, scinote(lowercase(str))) - end - c, q = parameters(('.' in str) ? split(str, '.') : str) - normalize(Decimal(str[1] == '-', c, q)) -end - -decimal(str::AbstractString) = parse(Decimal, str) - -# Convert a number to a decimal -Decimal(num::Real) = parse(Decimal, string(num)) -Base.convert(::Type{Decimal}, num::Real) = Decimal(num::Real) -decimal(x::Real) = Decimal(x) Decimal(x::Decimal) = x -# Get Decimal constructor parameters from string -parameters(x::AbstractString) = (abs(parse(BigInt, x)), 0) +# From real numbers to Decimal +Decimal(x::Real) = parse(Decimal, string(x)) +Base.convert(::Type{Decimal}, x::Real) = Decimal(x) -# Get Decimal constructor parameters from array -function parameters(x::Array) - c = parse(BigInt, join(x)) - (abs(c), -length(x[2])) -end +# From Decimal to numbers +(::Type{T})(x::Decimal) where {T<:Number} = parse(T, string(x)) -# Get decimal() argument from scientific notation -function scinote(str::AbstractString) - s = (str[1] == '-') ? "-" : "" - n, expo = split(str, 'e') - n = split(n, '.') - if s == "-" - n[1] = n[1][2:end] - end - if parse(Int64, expo) >= 0 - shift = parse(Int64, expo) - ((length(n) == 2) ? length(n[2]) : 0) - s * join(n) * repeat("0", shift) - else - shift = -parse(Int64, expo) - ((length(n) == 2) ? length(n[1]) : length(n)) - s * "0." * repeat("0", shift) * join(n) - end -end - -# Convert a decimal to a string -function Base.print(io::IO, x::Decimal) - c = string(x.c) - negative = (x.s == 1) ? "-" : "" - if x.q > 0 - print(io, negative, c, repeat("0", x.q)) - elseif x.q < 0 - shift = x.q + length(c) - if shift > 0 - print(io, negative, c[1:shift], ".", c[(shift+1):end]) - else - print(io, negative, "0.", repeat("0", -shift), c) - end - else - print(io, negative, c) - end +# String representation of Decimal +function Base.string(x::Decimal) + io = IOBuffer() + show_plain(io, x) + return String(take!(io)) end # Zero/one value @@ -71,14 +24,5 @@ Base.iszero(x::Decimal) = iszero(x.c) Base.isfinite(x::Decimal) = true Base.isnan(x::Decimal) = false -# convert a decimal to any subtype of Real -(::Type{T})(x::Decimal) where {T<:Real} = parse(T, string(x)) - -# Convert a decimal to an integer if possible, a float if not -function number(x::Decimal) - ix = (str = string(x) ; fx = parse(Float64, str); round(Int64, fx)) - (ix == fx) ? ix : fx -end - # sign Base.signbit(x::Decimal) = x.s diff --git a/src/parse.jl b/src/parse.jl new file mode 100644 index 0000000..716c4d7 --- /dev/null +++ b/src/parse.jl @@ -0,0 +1,47 @@ +function Base.tryparse(::Type{Decimal}, str::AbstractString) + regex = Regex(string( + "^", + # Optional sign + "(?[+-])?", + # Decimal part: either 1[234].[567] or [.]1[234] + "(?(?:\\d+\\.\\d*)|(?:\\.?\\d+))", + # Optional exponent part: e or E followed by optional sign and digits + "(?[eE][+-]?\\d+)?", + "\$" + )) + + m = match(regex, str) + if isnothing(m) + return nothing + end + + sign = m[:sign] + deci_part = m[:dec] + expo_part = m[:exp] + + # expo_part[1] is 'e' or 'E' + exponent = isnothing(expo_part) ? 0 : parse(Int, @view expo_part[2:end]) + + int_frac = split(deci_part, ".", keepempty=true) + if length(int_frac) == 1 + # There is no decimal point + coef = parse(BigInt, deci_part) + else + # There is a decimal point + @assert length(int_frac) == 2 + int, frac = int_frac + coef = parse(BigInt, int * frac) + exponent -= length(frac) + end + + negative = sign == "-" + + return Decimal(negative, coef, exponent) +end + +function Base.parse(::Type{Decimal}, str::AbstractString) + x = tryparse(Decimal, str) + isnothing(x) && throw(ArgumentError("Invalid decimal: $str")) + return x +end + diff --git a/src/show.jl b/src/show.jl new file mode 100644 index 0000000..5b39f85 --- /dev/null +++ b/src/show.jl @@ -0,0 +1,64 @@ +# Show x without using exponential notation +function show_plain(io::IO, x::Decimal) + if x.s + print(io, '-') + end + + if x.q ≥ 0 + # Print coefficient and `x.q` zeros + print(io, x.c, repeat('0', x.q)) + else # x.q < 0 + coef = string(x.c) + coefdigits = ncodeunits(coef) + pointpos = -x.q # How many digits should go after the decimal point + + # If there are some (non-zero) digits before the decimal point, + # print them, then print the decimal point, and then the digits after + # the decimal point + # Otherwise, print "0." and then print zeros so that the number of + # zeros plus `coefdigits` is `pointpos` + if pointpos < coefdigits + print(io, @view(coef[1:end - pointpos]), ".", + @view(coef[end - pointpos + 1:end])) + else + print(io, "0.", repeat('0', pointpos - coefdigits), coef) + end + end +end + +# Show x using exponential notation +function show_exponential(io::IO, x::Decimal) + coef = string(x.c) + coefdigits = ncodeunits(coef) + + if x.s + print(io, '-') + end + + # If there are more than one digit, + # put a decimal point right after the first digit + if coefdigits > 1 + print(io, coef[1], ".", @view coef[2:end]) + else + print(io, coef) + end + + adjusted_exp = x.q + coefdigits - 1 + exp_sign = adjusted_exp > 0 ? '+' : '-' + print(io, "E", exp_sign, abs(adjusted_exp)) +end + +# Prints `x` using the scientific notation +function scientific_notation(io::IO, x::Decimal) + adjusted_exp = x.q + ndigits(x.c) - 1 + + # Decide whether to use the exponential notation + if x.q ≤ 0 && adjusted_exp ≥ -6 + show_plain(io, x) + else + show_exponential(io, x) + end +end + +Base.show(io::IO, x::Decimal) = print(io, "Decimal($(Int(x.s)), $(x.c), $(x.q))") +Base.show(io::IO, ::MIME"text/plain", x::Decimal) = scientific_notation(io, x) diff --git a/test/runtests.jl b/test/runtests.jl index d15616c..cce1169 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -15,6 +15,8 @@ global d = [ include("test_arithmetic.jl") include("test_bigint.jl") include("test_constructor.jl") +include("test_parse.jl") +include("test_show.jl") include("test_decimal.jl") include("test_equals.jl") include("test_hash.jl") diff --git a/test/test_arithmetic.jl b/test/test_arithmetic.jl index c07413d..7c7fa71 100644 --- a/test/test_arithmetic.jl +++ b/test/test_arithmetic.jl @@ -21,7 +21,7 @@ end @testset "Negation" begin @test -Decimal.([0.3 0.2]) == [-Decimal(0.3) -Decimal(0.2)] @test -Decimal(0.3) == zero(Decimal) - Decimal(0.3) - @test iszero(decimal(12.1) - decimal(12.1)) + @test iszero(Decimal(12.1) - Decimal(12.1)) end @testset "Multiplication" begin diff --git a/test/test_decimal.jl b/test/test_decimal.jl index 4e2684e..851362d 100644 --- a/test/test_decimal.jl +++ b/test/test_decimal.jl @@ -5,35 +5,25 @@ using Test @testset "String/Number to Decimal" begin @testset "Direct" begin - @test parse(Decimal, "0.01") == Decimal(0.01) == Decimal(false, 1, -2) - @test parse(Decimal, ".001") == Decimal(.001) == Decimal(false, 1, -3) - @test parse(Decimal, "15.23") == Decimal(15.23) == Decimal(false, 1523, -2) - @test parse(Decimal, "543") == Decimal(543) == Decimal(false, 543, 0) - @test parse(Decimal, "-345") == Decimal(-345) == Decimal(true, 345, 0) - @test parse(Decimal, "000123") == Decimal(000123) == Decimal(false, 123, 0) - @test parse(Decimal, "-00032") == Decimal(-00032) == Decimal(true, 32, 0) - @test parse(Decimal, "200100") == Decimal(200100) == Decimal(false, 2001, 2) - @test parse(Decimal, "-.123") == Decimal(-.123) == Decimal(true, 123, -3) - @test parse(Decimal, "1.23000") == Decimal(1.23000) == Decimal(false, 123, -2) - @test parse(Decimal, "4734.612") == Decimal(4734.612) == Decimal(false, 4734612, -3) - @test parse(Decimal, "541724.2") == Decimal(541724.2) == Decimal(false,5417242,-1) - @test parse(Decimal, "2.5e6") == Decimal(2.5e6) == Decimal(false, 25, 5) - @test parse(Decimal, "2.385350e8") == Decimal(2.385350e8) == Decimal(false, 238535, 3) - @test parse(Decimal, "12.3e-4") == Decimal(12.3e-4) == Decimal(false, 123, -5) - - @test parse(Decimal, "-12.3e4") == Decimal(-12.3e4) == Decimal(true, 123, 3) - - @test parse(Decimal, "-12.3e-4") == Decimal(-12.3e-4) == Decimal(true, 123, -5) - @test parse(Decimal, "-12.3E-4") == Decimal(true, 123, -5) - - @test parse(Decimal, "0.1234567891") == Decimal(0.1234567891) == Decimal(false,1234567891, -10) - @test parse(Decimal, "0.12345678912") == Decimal(0.12345678912) == Decimal(false,12345678912, -11) - end - - @testset "Using `decimal`" begin - @test decimal("1.0") == Decimal(false, 1, 0) - @test decimal(8.1) == Decimal(false, 81, -1) - @test decimal.(Float64.(d)) == d + @test Decimal(0.01) == Decimal(false, 1, -2) + @test Decimal(.001) == Decimal(false, 1, -3) + @test Decimal(15.23) == Decimal(false, 1523, -2) + @test Decimal(543) == Decimal(false, 543, 0) + @test Decimal(-345) == Decimal(true, 345, 0) + @test Decimal(000123) == Decimal(false, 123, 0) + @test Decimal(-00032) == Decimal(true, 32, 0) + @test Decimal(200100) == Decimal(false, 2001, 2) + @test Decimal(-.123) == Decimal(true, 123, -3) + @test Decimal(1.23000) == Decimal(false, 123, -2) + @test Decimal(4734.612) == Decimal(false, 4734612, -3) + @test Decimal(541724.2) == Decimal(false,5417242,-1) + @test Decimal(2.5e6) == Decimal(false, 25, 5) + @test Decimal(2.385350e8) == Decimal(false, 238535, 3) + @test Decimal(12.3e-4) == Decimal(false, 123, -5) + @test Decimal(-12.3e4) == Decimal(true, 123, 3) + @test Decimal(-12.3e-4) == Decimal(true, 123, -5) + @test Decimal(0.1234567891) == Decimal(false,1234567891, -10) + @test Decimal(0.12345678912) == Decimal(false,12345678912, -11) end end @@ -65,19 +55,6 @@ end @test BigInt(Decimal(false, 2001, 2)) == 200100 @test BigFloat(Decimal(true, 123, -3)) == big"-0.123" @test Float64(Decimal(false, 123, -2)) == 1.23 - @test number(Decimal(false, 1, -2)) == 0.01 - @test number(Decimal(false, 1, -3)) == 0.001 - @test number(Decimal(false, 1523, -2)) == 15.23 - @test number(Decimal(false, 543, 0)) == 543 - @test number(Decimal(true, 345, 0)) == -345 - @test number(Decimal(false, 123, 0)) == 123 - @test number(Decimal(true, 32, 0)) == -32 - @test number(Decimal(false, 2001, 2)) == 200100 - @test number(Decimal(true, 123, -3)) == -0.123 - @test number(Decimal(false, 123, -2)) == 1.23 - @test string(Float64(Decimal(false, 543, 0))) == "543.0" - @test string(number(Decimal(false, 543, 0))) == "543" - @test string(number(Decimal(false, 543, -1))) == "54.3" end @testset "Number functions" begin diff --git a/test/test_equals.jl b/test/test_equals.jl index 07c6529..b850af3 100644 --- a/test/test_equals.jl +++ b/test/test_equals.jl @@ -31,8 +31,6 @@ end @test Decimal(bi) == bi @test bi == Decimal(bi) - @test decimal(12.1) == decimal(12.1) - @test Decimal(true, 0, -1) == Decimal(false, 0, 0) end @@ -43,7 +41,6 @@ end @test !(Decimal(true, 0, 1) < Decimal(true, 1, 1)) @test Decimal(false, 2, -3) < Decimal(false, 2, 3) @test !(Decimal(false, 2, 3) < Decimal(false, 2, -3)) - @test !(decimal(12.1) < decimal(12.1)) @test !(Decimal(true, 0, -1) < Decimal(false, 0, 0)) @test !(Decimal(false, 0, 0) < Decimal(true, 0, -1)) end @@ -55,7 +52,6 @@ end @test Decimal(1, 0, 1) > Decimal(1, 1, 1) @test !(Decimal(0, 2, -3) > Decimal(0, 2, 3)) @test Decimal(0, 2, 3) > Decimal(0, 2, -3) - @test !(decimal(12.1) > decimal(12.1)) @test !(Decimal(1, 0, -1) > Decimal(0, 0, 0)) @test !(Decimal(0, 0, 0) > Decimal(1, 0, -1)) end @@ -67,7 +63,6 @@ end @test !(Decimal(1, 0, 1) <= Decimal(1, 1, 1)) @test Decimal(0, 2, -3) <= Decimal(0, 2, 3) @test !(Decimal(0, 2, 3) <= Decimal(0, 2, -3)) - @test decimal(12.1) <= decimal(12.1) @test Decimal(1, 0, -1) <= Decimal(0, 0, 0) @test Decimal(0, 0, 0) <= Decimal(1, 0, -1) end @@ -79,7 +74,6 @@ end @test Decimal(1, 0, 1) >= Decimal(1, 1, 1) @test !(Decimal(0, 2, -3) >= Decimal(0, 2, 3)) @test Decimal(0, 2, 3) >= Decimal(0, 2, -3) - @test decimal(12.1) >= decimal(12.1) @test Decimal(1, 0, -1) >= Decimal(0, 0, 0) @test Decimal(0, 0, 0) >= Decimal(1, 0, -1) end diff --git a/test/test_parse.jl b/test/test_parse.jl new file mode 100644 index 0000000..fcd5a8d --- /dev/null +++ b/test/test_parse.jl @@ -0,0 +1,30 @@ +@testset "Parsing" begin + @testset "tryparse" begin + @test tryparse(Decimal, "0.01") == Decimal(false, 1, -2) + @test tryparse(Decimal, ".001") == Decimal(false, 1, -3) + @test tryparse(Decimal, "15.23") == Decimal(false, 1523, -2) + @test tryparse(Decimal, "543") == Decimal(false, 543, 0) + @test tryparse(Decimal, "-345") == Decimal(true, 345, 0) + @test tryparse(Decimal, "000123") == Decimal(false, 123, 0) + @test tryparse(Decimal, "-00032") == Decimal(true, 32, 0) + @test tryparse(Decimal, "200100") == Decimal(false, 2001, 2) + @test tryparse(Decimal, "-.123") == Decimal(true, 123, -3) + @test tryparse(Decimal, "1.23000") == Decimal(false, 123, -2) + @test tryparse(Decimal, "4734.612") == Decimal(false, 4734612, -3) + @test tryparse(Decimal, "541724.2") == Decimal(false,5417242,-1) + @test tryparse(Decimal, "2.5e6") == Decimal(false, 25, 5) + @test tryparse(Decimal, "2.385350e8") == Decimal(false, 238535, 3) + @test tryparse(Decimal, "12.3e-4") == Decimal(false, 123, -5) + @test tryparse(Decimal, "-12.3e4") == Decimal(true, 123, 3) + @test tryparse(Decimal, "-12.3e-4") == Decimal(true, 123, -5) + @test tryparse(Decimal, "-12.3E-4") == Decimal(true, 123, -5) + @test tryparse(Decimal, "0.1234567891") == Decimal(false,1234567891, -10) + @test tryparse(Decimal, "0.12345678912") == Decimal(false,12345678912, -11) + + @test isnothing(tryparse(Decimal, "1.1.1")) + @test isnothing(tryparse(Decimal, "1f2")) + @test isnothing(tryparse(Decimal, " 1")) + @test isnothing(tryparse(Decimal, "1e1e1")) + @test isnothing(tryparse(Decimal, "1-1")) + end +end diff --git a/test/test_show.jl b/test/test_show.jl new file mode 100644 index 0000000..eb02e27 --- /dev/null +++ b/test/test_show.jl @@ -0,0 +1,23 @@ +@testset "Showing" begin + @testset "repr" begin + @test repr(Decimal(1, 2, 3)) == "Decimal(1, 2, 3)" + end + + @testset "Scientific notation" begin + repr("text/plain", Decimal(0,123,0)) == "123" + repr("text/plain", Decimal(1,123,0)) == "-123" + repr("text/plain", Decimal(0,123,1)) == "1.23E+3" + repr("text/plain", Decimal(0,123,3)) == "1.23E+5" + repr("text/plain", Decimal(0,123,-1)) == "12.3" + repr("text/plain", Decimal(0,123,-5)) == "0.00123" + repr("text/plain", Decimal(0,123,-10)) == "1.23E-8" + repr("text/plain", Decimal(1,123,-12)) == "-1.23E-10" + repr("text/plain", Decimal(0,0,0)) == "0" + repr("text/plain", Decimal(0,0,-2)) == "0.00" + repr("text/plain", Decimal(0,0,2)) == "0E+2" + repr("text/plain", Decimal(1,0,0)) == "-0" + repr("text/plain", Decimal(0,5,-6)) == "0.000005" + repr("text/plain", Decimal(0,50,-7)) == "0.0000050" + repr("text/plain", Decimal(0,5,-7)) == "5E-7" + end +end