Skip to content

Commit

Permalink
Reworked parsing & conversion
Browse files Browse the repository at this point in the history
  • Loading branch information
barucden committed Oct 17, 2024
1 parent 88e65fe commit c62635e
Show file tree
Hide file tree
Showing 12 changed files with 253 additions and 148 deletions.
81 changes: 52 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/Decimals.jl
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
module Decimals

export Decimal,
decimal,
number,
normalize

Expand Down Expand Up @@ -35,4 +34,8 @@ include("equals.jl")
# Rounding
include("round.jl")

include("parse.jl")

include("show.jl")

end
77 changes: 10 additions & 67 deletions src/decimal.jl
Original file line number Diff line number Diff line change
@@ -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)

# Get Decimal constructor parameters from array
function parameters(x::Array)
c = parse(BigInt, join(x))
(abs(c), -length(x[2]))
end
# From real numbers to Decimal
Decimal(x::Real) = parse(Decimal, string(x))
Base.convert(::Type{Decimal}, x::Real) = Decimal(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
# From Decimal to numbers
(::Type{T})(x::Decimal) where {T<:Number} = parse(T, string(x))

# 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
Expand All @@ -71,15 +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

2 changes: 0 additions & 2 deletions src/equals.jl
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ function Base.:(==)(x::Decimal, y::Decimal)
a.c == b.c && a.q == b.q && a.s == b.s
end

Base.iszero(x::Decimal) = iszero(x.c)

function Base.:(<)(x::Decimal, y::Decimal)
# return early on zero
if iszero(x) && iszero(y)
Expand Down
48 changes: 48 additions & 0 deletions src/parse.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
function Base.tryparse(::Type{Decimal}, str::AbstractString)
regex = Regex(string(
"^",
# Optional sign
"(?<sign>[+-])?",
# Decimal part: either 1[234].[567] or [.]1[234]
"(?<dec>(?:\\d+\\.\\d*)|(?:\\.?\\d+))",
# Optional exponent part: e or E followed by optional sign and digits
"(?<exp>[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)
elseif length(int_frac) == 2
# There is a decimal point
int, frac = int_frac
coef = parse(BigInt, int * frac)
exponent -= length(frac)
else
@assert false # should never happen

Check warning on line 35 in src/parse.jl

View check run for this annotation

Codecov / codecov/patch

src/parse.jl#L35

Added line #L35 was not covered by tests
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

64 changes: 64 additions & 0 deletions src/show.jl
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ global d = [
]

include("test_constructor.jl")
include("test_parse.jl")
include("test_show.jl")
include("test_decimal.jl")
include("test_norm.jl")
include("test_arithmetic.jl")
Expand Down
2 changes: 1 addition & 1 deletion test/test_arithmetic.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit c62635e

Please sign in to comment.