Skip to content

Commit

Permalink
Merge pull request #85 from JuliaMath/nhd-checked-math
Browse files Browse the repository at this point in the history
Add checked math to FixedDecimals; default to overflow behavior
  • Loading branch information
NHDaly authored Dec 19, 2023
2 parents dfc11f0 + 73410da commit e769be4
Show file tree
Hide file tree
Showing 5 changed files with 382 additions and 32 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
version:
- '1.6'
- '1'
# - 'nightly'
- 'nightly'
os:
- ubuntu-latest
- macOS-latest
Expand Down
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name = "FixedPointDecimals"
uuid = "fb4d412d-6eee-574d-9565-ede6634db7b0"
authors = ["Fengyang Wang <[email protected]>", "Curtis Vogt <[email protected]>"]
version = "0.4.4"
version = "0.5.0"

[deps]
Parsers = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0"
Expand Down
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,64 @@ julia> 0.1 + 0.2
julia> FixedDecimal{Int,1}(0.1) + FixedDecimal{Int,1}(0.2)
FixedDecimal{Int64,1}(0.3)
```

### Arithmetic details: Overflow and checked math

_NOTE: This section applies to FixedPointDecimals v0.5+._

By default, all arithmetic operations on FixedDecimals, except division, **will silently overflow** following the standard behavior for bit integer types in Julia. For example:
```julia
julia> FixedDecimal{Int8,2}(1.0) + FixedDecimal{Int8,2}(1.0)
FixedDecimal{Int8,2}(-0.56)

julia> -FixedDecimal{Int8,2}(-1.28) # negative typemin wraps to typemin again
FixedDecimal{Int8,2}(-1.28)

julia> abs(FixedDecimal{Int8,2}(-1.28)) # negative typemin wraps to typemin again
FixedDecimal{Int8,2}(-1.28)
```

*Note that **division** on FixedDecimals will throw OverflowErrors on overflow, and will not wrap. This decision may be reevaluated in a future breaking version change release of FixedDecimals. Please keep this in mind.*

In most applications dealing with `FixedDecimals`, you will likely want to use the **checked arithmetic** operations instead. These operations will _throw an OverflowError_ on overflow or underflow, rather than silently wrapping. For example:
```julia
julia> Base.checked_mul(FixedDecimal{Int8,2}(1.2), FixedDecimal{Int8,2}(1.2))
ERROR: OverflowError: 1.20 * 1.20 overflowed for type FixedDecimal{Int8, 2}

julia> Base.checked_add(FixedDecimal{Int8,2}(1.2), 1)
ERROR: OverflowError: 1.20 + 1.00 overflowed for type FixedDecimal{Int8, 2}

julia> Base.checked_div(Int8(1), FixedDecimal{Int8,2}(0.5))
ERROR: OverflowError: 1.00 ÷ 0.50 overflowed for type FixedDecimal{Int8, 2}
```

**Checked division:** Note that `checked_div` performs truncating, integer division. Julia Base does not provide a function to perform checked *decimal* division (`/`), so we provide one in this package, `FixedPointDecimals.checked_rdiv`. However, as noted above, the default division arithmetic operators will throw on overflow anyway.

Here are all the checked arithmetic operations supported by `FixedDecimal`s:
- `Base.checked_add(x,y)`
- `Base.checked_sub(x,y)`
- `Base.checked_mul(x,y)`
- `Base.checked_div(x,y)`
- `FixedPointDecimals.checked_rdiv(x,y)`
- `Base.checked_cld(x,y)`
- `Base.checked_fld(x,y)`
- `Base.checked_rem(x,y)`
- `Base.checked_mod(x,y)`
- `Base.checked_neg(x)`
- `Base.checked_abs(x)`

### Conversions, Promotions, and Inexact Errors.

Note that arithmetic operations will _promote_ all arguments to the same FixedDecimal type
before performing the operation. If you are promoting a non-FixedDecimal _number_ to a FixedDecimal, there is always a chance that the Number will not fit in the FD type. In that case, the conversion will throw an exception. Here are some examples:
```julia
julia> FixedDecimal{Int8,2}(2) # 200 doesn't fit in Int8
ERROR: InexactError: convert(FixedDecimal{Int8, 2}, 2)

julia> FixedDecimal{Int8,2}(1) + 2 # Same here: 2 is promoted to FD{Int8,2}(2)
ERROR: InexactError: convert(FixedDecimal{Int8, 2}, 2)

julia> FixedDecimal{Int8,2}(1) + FixedDecimal{Int8,1}(2) # Promote to the higher-precision type again throws.
ERROR: InexactError: convert(FixedDecimal{Int8, 2}, 2.0)
```

180 changes: 154 additions & 26 deletions src/FixedPointDecimals.jl
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ module FixedPointDecimals

export FixedDecimal, RoundThrows

# (Re)export checked_* arithmetic functions
# - Defined in this package:
export checked_rdiv
# - Reexported from Base:
export checked_abs, checked_add, checked_cld, checked_div, checked_fld,
checked_mod, checked_mul, checked_neg, checked_rem, checked_sub

using Base: decompose, BitInteger
import Parsers

Expand Down Expand Up @@ -187,28 +194,12 @@ end

# these functions are needed to avoid InexactError when converting from the
# integer type
Base.:*(x::Integer, y::FD{T, f}) where {T, f} = reinterpret(FD{T, f}, T(x * y.i))
Base.:*(x::FD{T, f}, y::Integer) where {T, f} = reinterpret(FD{T, f}, T(x.i * y))
Base.:*(x::Integer, y::FD{T, f}) where {T, f} = reinterpret(FD{T, f}, x * y.i)
Base.:*(x::FD{T, f}, y::Integer) where {T, f} = reinterpret(FD{T, f}, x.i * y)

function Base.:/(x::FD{T, f}, y::FD{T, f}) where {T, f}
powt = coefficient(FD{T, f})
quotient, remainder = fldmod(widemul(x.i, powt), y.i)
reinterpret(FD{T, f}, T(_round_to_nearest(quotient, remainder, y.i)))
end

# These functions allow us to perform division with integers outside of the range of the
# FixedDecimal.
function Base.:/(x::Integer, y::FD{T, f}) where {T, f}
powt = coefficient(FD{T, f})
powtsq = widemul(powt, powt)
quotient, remainder = fldmod(widemul(x, powtsq), y.i)
reinterpret(FD{T, f}, T(_round_to_nearest(quotient, remainder, y.i)))
end

function Base.:/(x::FD{T, f}, y::Integer) where {T, f}
quotient, remainder = fldmod(x.i, y)
reinterpret(FD{T, f}, T(_round_to_nearest(quotient, remainder, y)))
end
Base.:/(x::FD, y::FD) = checked_rdiv(x, y)
Base.:/(x::Integer, y::FD) = checked_rdiv(x, y)
Base.:/(x::FD, y::Integer) = checked_rdiv(x, y)

# integerification
Base.trunc(x::FD{T, f}) where {T, f} = FD{T, f}(div(x.i, coefficient(FD{T, f})))
Expand Down Expand Up @@ -359,17 +350,154 @@ for remfn in [:rem, :mod, :mod1, :min, :max]
end
# TODO: When we upgrade to a min julia version >=1.4 (i.e Julia 2.0), this block can be
# dropped in favor of three-argument `div`, below.
for divfn in [:div, :fld, :fld1, :cld]
# div(x.i, y.i) eliminates the scaling coefficient, so we call the FD constructor.
# We don't need any widening logic, since we won't be multiplying by the coefficient.
@eval Base.$divfn(x::T, y::T) where {T <: FD} = T($divfn(x.i, y.i))
# The division functions all default to *throwing OverflowError* rather than
# wrapping on integer overflow.
# This decision may be changed in a future release of FixedPointDecimals.
Base.div(x::FD, y::FD) = Base.checked_div(x, y)
Base.fld(x::FD, y::FD) = Base.checked_fld(x, y)
Base.cld(x::FD, y::FD) = Base.checked_cld(x, y)
# There is no checked_fld1, so this is implemented here:
function Base.fld1(x::FD{T,f}, y::FD{T,f}) where {T, f}
C = coefficient(FD{T, f})
# Note: fld1() will already throw for divide-by-zero and typemin(T) ÷ -1.
v, b = Base.Checked.mul_with_overflow(C, fld1(x.i, y.i))
b && _throw_overflowerr_op(:fld1, x, y)
return reinterpret(FD{T, f}, v)
end
if VERSION >= v"1.4.0-"
# div(x.i, y.i) eliminates the scaling coefficient, so we call the FD constructor.
# We don't need any widening logic, since we won't be multiplying by the coefficient.
Base.div(x::T, y::T, r::RoundingMode) where {T <: FD} = T(div(x.i, y.i, r))
@eval function Base.div(x::FD{T, f}, y::FD{T, f}, r::RoundingMode) where {T<:Integer, f}
C = coefficient(FD{T, f})
# Note: The div() will already throw for divide-by-zero and typemin(T) ÷ -1.
v, b = Base.Checked.mul_with_overflow(C, div(x.i, y.i, r))
b && _throw_overflowerr_op(:div, x, y)
return reinterpret(FD{T, f}, v)
end
end

# --- Checked arithmetic ---

Base.checked_add(x::FD, y::FD) = Base.checked_add(promote(x, y)...)
Base.checked_sub(x::FD, y::FD) = Base.checked_sub(promote(x, y)...)
Base.checked_mul(x::FD, y::FD) = Base.checked_mul(promote(x, y)...)
Base.checked_div(x::FD, y::FD) = Base.checked_div(promote(x, y)...)
Base.checked_cld(x::FD, y::FD) = Base.checked_cld(promote(x, y)...)
Base.checked_fld(x::FD, y::FD) = Base.checked_fld(promote(x, y)...)
Base.checked_rem(x::FD, y::FD) = Base.checked_rem(promote(x, y)...)
Base.checked_mod(x::FD, y::FD) = Base.checked_mod(promote(x, y)...)

Base.checked_add(x::FD, y) = Base.checked_add(promote(x, y)...)
Base.checked_add(x, y::FD) = Base.checked_add(promote(x, y)...)
Base.checked_sub(x::FD, y) = Base.checked_sub(promote(x, y)...)
Base.checked_sub(x, y::FD) = Base.checked_sub(promote(x, y)...)
Base.checked_mul(x::FD, y) = Base.checked_mul(promote(x, y)...)
Base.checked_mul(x, y::FD) = Base.checked_mul(promote(x, y)...)
Base.checked_div(x::FD, y) = Base.checked_div(promote(x, y)...)
Base.checked_div(x, y::FD) = Base.checked_div(promote(x, y)...)
Base.checked_cld(x::FD, y) = Base.checked_cld(promote(x, y)...)
Base.checked_cld(x, y::FD) = Base.checked_cld(promote(x, y)...)
Base.checked_fld(x::FD, y) = Base.checked_fld(promote(x, y)...)
Base.checked_fld(x, y::FD) = Base.checked_fld(promote(x, y)...)
Base.checked_rem(x::FD, y) = Base.checked_rem(promote(x, y)...)
Base.checked_rem(x, y::FD) = Base.checked_rem(promote(x, y)...)
Base.checked_mod(x::FD, y) = Base.checked_mod(promote(x, y)...)
Base.checked_mod(x, y::FD) = Base.checked_mod(promote(x, y)...)

function Base.checked_add(x::T, y::T) where {T<:FD}
z, b = Base.add_with_overflow(x.i, y.i)
b && Base.Checked.throw_overflowerr_binaryop(:+, x, y)
return reinterpret(T, z)
end
function Base.checked_sub(x::T, y::T) where {T<:FD}
z, b = Base.sub_with_overflow(x.i, y.i)
b && Base.Checked.throw_overflowerr_binaryop(:-, x, y)
return reinterpret(T, z)
end
function Base.checked_mul(x::FD{T,f}, y::FD{T,f}) where {T<:Integer,f}
powt = coefficient(FD{T, f})
quotient, remainder = fldmodinline(widemul(x.i, y.i), powt)
v = _round_to_nearest(quotient, remainder, powt)
typemin(T) <= v <= typemax(T) || Base.Checked.throw_overflowerr_binaryop(:*, x, y)
return reinterpret(FD{T, f}, T(v))
end
# Checked division functions
for divfn in [:div, :fld, :cld]
@eval function Base.$(Symbol("checked_$divfn"))(x::FD{T,f}, y::FD{T,f}) where {T<:Integer,f}
C = coefficient(FD{T, f})
# Note: The div() will already throw for divide-by-zero and typemin(T) ÷ -1.
v, b = Base.Checked.mul_with_overflow(C, $divfn(x.i, y.i))
b && _throw_overflowerr_op($(QuoteNode(divfn)), x, y)
return reinterpret(FD{T, f}, v)
end
end
for remfn in [:rem, :mod]
# rem and mod already check for divide-by-zero and typemin(T) ÷ -1, so nothing to do.
@eval Base.$(Symbol("checked_$remfn"))(x::T, y::T) where {T <: FD} = $remfn(x, y)
end

@noinline _throw_overflowerr_op(op, x::T, y::T) where T = throw(OverflowError("$op($x, $y) overflowed for type $T"))

function Base.checked_neg(x::T) where {T<:FD}
r = -x
(x<0) & (r<0) && Base.Checked.throw_overflowerr_negation(x)
return r
end
function Base.checked_abs(x::FD)
r = ifelse(x<0, -x, x)
r<0 || return r
_throw_overflow_abs(x)
end
if VERSION >= v"1.8.0-"
@noinline _throw_overflow_abs(x) =
throw(OverflowError(LazyString("checked arithmetic: cannot compute |x| for x = ", x, "::", typeof(x))))
else
@noinline _throw_overflow_abs(x) =
throw(OverflowError("checked arithmetic: cannot compute |x| for x = $x"))
end

# We introduce a new function for this since Base.Checked only supports integers, and ints
# don't have a decimal division operation.
"""
FixedPointDecimals.checked_rdiv(x::FD, y::FD) -> FD
Calculates `x / y`, checking for overflow errors where applicable.
The overflow protection may impose a perceptible performance penalty.
See also:
- `Base.checked_div` for truncating division.
"""
checked_rdiv(x::FD, y::FD) = checked_rdiv(promote(x, y)...)

function checked_rdiv(x::FD{T,f}, y::FD{T,f}) where {T<:Integer,f}
powt = coefficient(FD{T, f})
quotient, remainder = fldmod(widemul(x.i, powt), y.i)
v = _round_to_nearest(quotient, remainder, y.i)
typemin(T) <= v <= typemax(T) || Base.Checked.throw_overflowerr_binaryop(:/, x, y)
return reinterpret(FD{T, f}, v)
end

# These functions allow us to perform division with integers outside of the range of the
# FixedDecimal.
function checked_rdiv(x::Integer, y::FD{T, f}) where {T<:Integer, f}
powt = coefficient(FD{T, f})
powtsq = widemul(powt, powt)
quotient, remainder = fldmod(widemul(x, powtsq), y.i)
v = _round_to_nearest(quotient, remainder, y.i)
typemin(T) <= v <= typemax(T) || Base.Checked.throw_overflowerr_binaryop(:/, x, y)
reinterpret(FD{T, f}, v)
end
function checked_rdiv(x::FD{T, f}, y::Integer) where {T<:Integer, f}
quotient, remainder = fldmod(x.i, y)
v = _round_to_nearest(quotient, remainder, y)
typemin(T) <= v <= typemax(T) || Base.Checked.throw_overflowerr_binaryop(:/, x, y)
reinterpret(FD{T, f}, v)
end


# --------------------------

Base.convert(::Type{AbstractFloat}, x::FD) = convert(floattype(typeof(x)), x)
function Base.convert(::Type{TF}, x::FD{T, f}) where {TF <: AbstractFloat, T, f}
convert(TF, x.i / coefficient(FD{T, f}))::TF
Expand Down
Loading

2 comments on commit e769be4

@NHDaly
Copy link
Member Author

@NHDaly NHDaly commented on e769be4 Dec 19, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JuliaRegistrator register()

@JuliaRegistrator
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Registration pull request created: JuliaRegistries/General/97419

Tip: Release Notes

Did you know you can add release notes too? Just add markdown formatted text underneath the comment after the text
"Release notes:" and it will be added to the registry PR, and if TagBot is installed it will also be added to the
release that TagBot creates. i.e.

@JuliaRegistrator register

Release notes:

## Breaking changes

- blah

To add them here just re-invoke and the PR will be updated.

Tagging

After the above pull request is merged, it is recommended that a tag is created on this repository for the registered package version.

This will be done automatically if the Julia TagBot GitHub Action is installed, or can be done manually through the github interface, or via:

git tag -a v0.5.0 -m "<description of version>" e769be49b96fd264207ad8eaa4a5ea25c75afb9b
git push origin v0.5.0

Please sign in to comment.