Should Any have an identity constructor method?

Many non-concrete types have constructors, for example we have AbstractFloat(0.3) == 0.3 and Union{Int,Float64}(0.3) == 0.3. Should we also have a method like Any(x) = x? Then we would have map(Any, [3,4,5]) == map(identity, [3,4,5]).

Xref: generalize the `Number` identity constructor by nsajko · Pull Request #53130 · JuliaLang/julia · GitHub

Is there any real application to that? E.g. are some codes producing error messages?

Usually, one should anyway prefer convert(Any, x) to convert to another datatype.

Adding such a method would make Julia a bit more internally consistent, so a bit friendlier to those who are still learning the language.

I just saw the GitHub PR. However, it is unclear to me if the right answer would be in that case that one should use convert instead of assuming a constructor exists.

E.g. referring to your PR, the following works already

 convert(Union{String, Int}, 4)

I could imagine that there are many more corner cases where a constructor is missing for some weird type or intentionally not allowed, but it is reasonable to have a convert.

Yes, convert exists, but TBH that’s not really relevant to this topic, as we already do have many non-concrete constructors.

1 Like

We need to clearly distinguish conversion behavior and identity behavior, which is a little weird because convert does both.

For converts where the input type does not match the target type, convert has conversion methods. For Numbers, convert(::Type{T}, x::Number) where {T<:Number} = T(x)::T forwards to the specified type’s constructor that implements the conversions. Note this includes defined Number subtypes like AbstractFloat, not type unions that aren’t specifically defined, so pushing 1 to a Vector{Union{Float32, Float64}} errors even though it would work for a Vector{Float32} or Vector{Float64}.

For converts where identity behavior occurs, convert has identity-like methods. I don’t know why there’s so many, but there’s one for Type{T}, Any, and Number in Base.

The constructors with identity behavior like Union{Int,Float64}(0.3), AbstractFloat(0.3), and even Float64(0.3) actually use (::Type{T})(x::T) where {T<:Number} = x from boot.jl in Core. These are not used by convert with identity behavior. It makes sense for this fallback to exist because it’d be strange if the specifically defined types’ constructors only worked on different types, but again this additionally covers type unions.

Taking all that together, it seems like the identity behavior is only as flexible and justified as it is for Numbers where constructors also implement type conversion. There’s no reason to replicate this identity behavior for other types like Function and Any where you don’t want conversion e.g. what on earth would Function(0.3) do? Any being the universal supertype shouldn’t ever convert, sure, but there’s no reason to do this when convert exists to handle both conversion and identity behavior for all types. It’s informative to let Function and Any lack any constructors like other defined abstract types by default, even printing so in the MethodError instead of listing close method matches.

I’m confused. Why are you bringing up convert and Function? This seems completely off-topic.

BTW I’m definitely not advocating for Function(0.3) to do anything but throw (0.3 isa Function doesn’t hold), as it already does.

Because SteffenPL brought up convert(Any, x) for type conversion, and Function is a constructor-less abstract type like Any is. Function and Any also happen to share the limitation of being disallowed type annotations of the callable in a method definition. If you’re saying that type conversion is unrelated to identity, that’s my point. convert(Any, x) does not perform type conversion as SteffenPL said, it uses an identity-like method.

Despite that, I agree that identity should be used for identity and convert should be used for both conversion and identity (which is something you want for generic functions), instead of adding identity constructors beyond Number. Feature bloat is already bad, this would be non-feature bloat because we already have clearer and better ways. It wouldn’t actually be consistent for the language because 1) it’s normal for behaviors to be limited to an abstract type like Number and all its subtypes, 2) Any shouldn’t ever convert inputs like Number subtypes do, 3) identity constructors would still not exist for most subtypes of Any, which would lack the consistency of Number described in (1).

This would be breaking to add in general, because constructors should create new, independent instances, e.g.:

julia> v = [1,2,3];

julia> v2 = Vector{Int}(v);

julia> v === v2
false

So having (::Type{T1})(x::T2) where {T1, T2 <: T1} = x would at the very least violate this rule, and perhaps even be breaking. It’s fine for Number because Numbers are immutable (this is undocumented, but it’s assumed to be true in Julia), and so new instances are indistinguishable from old ones.

3 Likes

Technically mutable Numbers are an important minority, though it beats me how you mutate them:

julia> x = BigInt(3)
3

julia> y = BigInt(x) # identity constructor
3

julia> x === y
true

julia> ismutable(x)
true

BigInt and BigFloat are semantically immutable, meaning they can technically be mutated by reaching into internal functions, but Base Julia assumes one never does that. This is a dumb, undocumented gotcha in Julia, and based on the idea that Numbers are always immutable (despite their implementation).

For example

julia> f() = big"1" # always returns 1
f (generic function with 1 method)

julia> x = Base.GMP.MPZ.add!(f(), big"2")
3

julia> f() # oops
3
4 Likes