Differing construction and conversion for subtypes of Number

After reading a few threads here, I’ve come to understand that for subtypes of Number, construction and conversion should have nearly identical behavior (with one exception: it is expected that construction always returns a new object – relevant for types that are not pure bits).

I’ve been writing packages that implement new Number subtypes, and I’ve come across some cases where it may be desirable for conversion and construction behavior to differ.

Case 1: construction as grade projection in CliffordNumbers.jl

My package, CliffordNumbers.jl, implements several types representing a Clifford number (multivector), elements of a Clifford algebra (geometric algebra). Clifford algebras admit a basis spanned by 2^D k-blades (k wedge products of 1-blades), where the grade k ranges from zero to D, the dimension of the space.

The CliffordNumber type is a dense representation of all coefficients associated with each basis blade of a multivector. But in practice, you can get away with sparser representations only representing blades of even grade (EvenCliffordNumber), odd grade (OddCliffordNumber), or a single grade K (KVector{K}).

Construction of a KVector{K,Q} from an EvenCliffordNumber{Q} is interpreted as grade projection: drop all coefficients for basis blades that are not grade K. This operation always succeeds, even if the result does not represent the same value as the input:

julia> x = EvenCliffordNumber{VGA(3)}(1, 2, 3, 4)
4-element EvenCliffordNumber{VGA(3), Int64}:
1 + 2e₁e₂ + 3e₁e₃ + 4e₂e₃

julia> KVector{2}(x) # Grade 0 (scalar) portion is dropped
3-element KVector{2, VGA(3), Int64}:
2e₁e₂ + 3e₁e₃ + 4e₂e₃

julia> ans == x
false

This operation always succeeds, unlike conversion, which fails if the value is not representable as a KVector{K,Q}, throwing an InexactError:

julia> convert(KVector{2}, x)
ERROR: InexactError: convert(KVector{2}, EvenCliffordNumber{VGA(3), Int64}(1, 2, 3, 4))
Stacktrace:
 [1] convert(T::Type{KVector{2}}, x::EvenCliffordNumber{VGA(3), Int64, 4})
   @ CliffordNumbers ~/.julia/packages/CliffordNumbers/hka3J/src/convert.jl:4
 [2] top-level scope
   @ REPL[49]:1

Case 2: Sign type

My unregistered package SignType.jl implements a Sign type: an abstraction of the signbit of a signed integer, float, or other real number – logically identical to a Bool but arithmetically distinct. (Perhaps I could also call it Int1 since it subtypes Signed.)

In general, Sign(x::Real) is identical to reinterpret(Sign, signbit(x)). This provides reasonable defaults for inputs that are zero:

julia> Sign(0)
Sign(+)

julia> Sign(-0.0)
Sign(-)

However, conversion of a zero element to a Sign throws an InexactError, even if the type can represent signed zero:

julia> convert(Sign, -0.0)
ERROR: InexactError: convert(Sign, -0.0)
Stacktrace:
 [1] convert(::Type{Sign}, x::Float64)
   @ SignType ~/git/SignType.jl/src/SignType.jl:147
 [2] top-level scope
   @ REPL[68]:1

The reason for this is that Sign can only represent the values +1 and -1. Even if the input type has a signed representation of zero, Sign cannot represent 0.

(Note: right now, convert(Sign, x) does not throw an error if x represents something other than +1 or -1, and just calls Sign(x). The behavior may change to reflect the logic above, so that only representations of +1 and -1 can be converted to Sign. I still haven’t finalized the design.)

The questions

From the Julia manual:

However, there is a key semantic difference: since convert can be called implicitly, its methods are restricted to cases that are considered “safe” or “unsurprising”.

Offhand, I anticipate it would be surprising in some circumstances if 0 supplied to a constructor for a type containing a Sign field was implicitly converted to +1 or -1. And while CliffordNumbers.jl is a niche library, I know there are definitely times where implicit grade projection done by the constructor can cause serious issues.

From my interpretation, in general:

  • the conversion convert(T, x) means “represent the value x using the type T”. While there may be some loss of precision, you’d expect its result to be equal (== or ) to x.
  • the constructor T(x) means “produce an instance of T using information from x.” This does not imply that T(x) needs to be equal to x in any sense.

Although the default implementation for a Number treats them as the same operation, I think I’ve found cases where it makes sense for them to differ. However, unlike iterators and arrays, there isn’t a documented interface for numbers as far as I know. So my questions are:

  • Is my interpretation of the semantics of construction vs. conversion correct?
  • Are these semantics necessarily more narrow for Number?
  • What, if anything, can break if construction and conversion differ for a subtype of Number?