How can I extend a primitive type

I’d like to implement a Percent type, which will be a subtype of Float64 and can utilise Float64. Ideally I’d like to something like this:

struct Percent <: Float64 
    v::Float64
    @assert 0≤v≤1 # I'm not sure if this is possible without an extra package?
end

Base.show(io::IO, p::Percent) = print(io, p*100, "%")

@test Percent(0.3)*10 == 3

I’ve read through the types docs but can only get it to work like Percent(0.3).v*10 which means I’m just using the Float anyway.

Concrete types in Julia can’t have subtypes. You probably just want to subtype Real.

4 Likes

This is not the sort of constraint you can easily impose in the type domain — it has to be checked at runtime — so it might not be worthwhile to implement this as a type.

For a runtime constraint like this, I would normally just define an ispercent(x::Real) = 0 ≤ x ≤ 1 function and check it as needed, maybe also defining a checkpercent(x) = ispercent(x) || throw(DomainError("$x is not in [0,1]")) function to throw errors. (You wouldn’t use @assert because assertions can be turned off — they are to check for bugs, not for users passing invalid data.)

4 Likes

Oscar directly answered your question. However, consider if that’s what you really want.

The way I understand it is that Percent <: Real is a real number restricted to the range [0, 1]. But then the following are valid operations:

Percent(0.8) + Percent(0.4) # 1.2
Percent(0.2) + 1000 # 1000.2

Not only their result is not a Percent anymore (which would be fine), they might be simply wrong if by “percent” you mean a proportion of something, e.g.,

probabilities_A = [Percent(3 / 4), Percent(1 / 4)]
probabilities_B = [Percent(1 / 2), Percent(1 / 2)]
probabilities_A[1] + probabilities_B[1] # You probably never want this

Therefore, if you want to use Percent as a proportion of something, it might be a good idea to keep it simple:

struct Percent
    v::Float64
    function Percent(v)
        # validate 0 <= v <= 1
        new(v)
    end
end

percent_of(p::Percent, n::Number) = p.v * n
# or maybe
import Base.*
(*)(p::Percent, n::Number) = p.v * n
3 Likes

Why can’t a percentage be outside the interval [0, 1]? What happens if you get a 150% pay raise? Do you say “No thank you, that’s not a percentage I’m comfortable with.”

7 Likes

Thanks for all your answers.

How would I subtype it? Given I’d like Percent(0.3)*2 to work? The docs suggest “Abstract types cannot be instantiated” but Real can be with Real(0.3). Clearly I’m miss-understanding something here.

Okay, so the constructor wouldn’t be doing the check? You’d have:

my_percent = 0.3
checkpercent(my_percent) || foo_requires_percent(my_percent)

Ideally rather than having foo_requires_percent I’d prefer foo(bar::Percent)

Sure.

Good point. I’m interested in how I’d do this from both a proportion point of view, or not (Percent(10)=1000% being valid)

Ideally I’d prefer to inherit these methods from the parent type given that Percent <: Real

No:

julia> typeof(Real(0.3))
Float64
2 Likes

Ah, so this was my miss-understanding, effectively there’s a function Real(x::Float64)=Float64(x)? Although I presume this isn’t the actual implementation.

Yes, the actual implementation is

(::Type{T})(x::T) where {T<:Number} = x

A bit hard to read, but it means that for any type T, if the type of x is a subtype of T, just return x itself.

1 Like

I don’t belive this comment is very constructive, it is clearly an application specific consideration. In my scenario I’m interested in considering any percentage and

Well, excuse me. I was just pointing out that percentages are not restricted to be in any particular range, and it was not clear to me why it should be. And I was trying to be funny.

OK, I’ll stop bothering you.

4 Likes

You haven’t really told us what you are doing in a more complete sense, but I have a hard time believing that making a special number type is really going to be worth your trouble. The only thing that differentiates your idea of a Percent from a Float64 is the domain restriction. Just throw that @assert line everywhere and move on!

1 Like

I appreciate your point if it is difficult. I presumed it would be trivial, in Python it is rather trivial:

class Percent(float):
    def __init__(self, v) -> None:
        if v > 100:
            raise ValueError("percent must be <= 100")
        if v < 0:
            raise ValueError("percent must be >= 0")
        self.value = v
>>> Percent(20)*2
40.0

Note: As already outlined the checks would be option.

Depending on where things go this would make it easier for me to extend it. For example I could make a currency type USD <: Real, now I can have to_GBP(amount::USD) and to_GBP(amount::EUR). For me these types make it possible to write cleaner function signatures and leverage multiple dispatch further. But perhaps I’m missing a point.

My point is that you perhaps should not consider Percent to be a special Real number, because it makes no sense to compute, e.g., 3% + 3.5.

Percents are usually used to express relative amounts with respect to some base quantity. In that case, it also makes no sense to compute, e.g., 100*(5/20)% + 100*(3/12)% because the base quantities (denominators) are different. Yet, 100*5/20 + 100*3/12 is a perfectly okay expression for real numbers.

So I suggest not to subtype Percent <: Real if by “percent” you mean a ratio.

If by “percent” you just mean “a real number that is between 0 and 1”, then it is probably not worth it to create a new type for that (as @tbeason said). You could just do

function percent(v) 
    0 ≤ v ≤ 1 || throw(DomainError("$v is outside [0, 1]"))
    return v
end

Lastly, if you really really wanted to create a custom numeric type, you would have to define promotion rules and conversion methods.

Check GitHub - PainterQubits/Unitful.jl: Physical quantities with arbitrary units.

1 Like

No, you’d have

function foo_requires_percent(p::Real)
    checkpercent(p) # throws DomainError if p ∉ [0,1]
    ... do stuff ...
end

i.e. you’d add an argument check to every function that requires its argument to be in [0,1], but allow the caller to pass an instance of any Real type.

Your proposed alternative of foo_requires_percent(p::Percent) just forces the user to call it as foo_requires_percent(Percent(0.1)), which still does a runtime check, potentially requires code at every call site instead of once in the function, is more annoying to use, and requires a bunch of boilerplate if you want Percent to otherwise act exactly like Float64.

1 Like

Thank you, these are good points you raise, and I agree percent is perhaps best to not subtype Real.

I’ve been looking through Unitful, the docs show how to dispatch on dimensions but not units? Otherwise Unitful seems like a good option for that scenario. I’ll keep looking into it.

julia> @unit USD "USD" USD 1 false
USD

julia> foo(x::USD) = x
ERROR: ArgumentError: invalid type for argument x in method definition for foo at REPL[42]:1
Stacktrace:
...

I see, this would require diligent usage of checkpercent. It is effectively extracting type checking away from the type, which as you suggest could reduce the number of calls. I appreciate this is a solution though.

My proposal was to have

foo(bar::Percent)=...
foo(bar::USD)=...
foo(bar::Float64)=...

Leveraging Julia’s multiple dispatch capabilities and gaining the benefits this provides.

Perhaps what I’m after isn’t idiomatic Julia.

No more diligent than putting ::Percent in every method where you want to impose this constraint on an argument.

In contrast, units (ala Unitful.jl) do not simply impose constraints on values — they track how units change as you pass them through operations like x^2 or x/y, detect compatibility of different combinations, and moreover they can do this statically (at compile time, so expressing information in the type domain is a big win).

It’s hard to judge what you want based on “foo”, but I suspect that this is too much punning — if foo is doing something completely different for a percentage than for a Float64, why should the two functions have the same name? And if it is doing the same thing, why can’t you write a single foo(bar::Real)?

(You don’t generally use type dispatch to differentiate behavior based on value information.)

3 Likes

Perhaps we are splitting hairs. I type the majority of functions I write so for me it is already engrained.

Sure, perhaps I’m being too abstract. Here is a more concrete example which I’d use multiple dispatch for:

function new_salary(current_salary::Real, change::Percent=Percent(150)
    return current_salary * (1 + change)
end

function new_salary(current_salary::Real, change::Real)
    return current_salary + change
end

@DNF :slight_smile:

I think they are similar enough that multiple dispatch isn’t too confusing.

On the contrary, I think this is a perfect case for spelling them differently. It seems a heck of a lot clearer to spell the two functions as current_salary + change and current_salary * (1 + change). :wink:

(This is the problem with trying to argue about type design in the abstract or from unrealistic toy examples.)

2 Likes