`map` should be more consistent

Dear all,

I wonder if it is proper to amend this behavior?

julia> map(_ -> 0, 0)
0

julia> struct Nvmber end

julia> Base.ndims(::Nvmber) = 0

julia> Base.length(::Nvmber) = 1

julia> Base.iterate(f::Nvmber) = (f, missing) # The `missing` here can be anything      

julia> Base.iterate(f::Nvmber, state) = nothing

julia> map(_ -> Nvmber(), Nvmber())
1-element Vector{Nvmber}:
 Nvmber()

julia> map(_ -> Nvmber(), 1)
Nvmber()

Note that 0 (or any numbers) is a point in a Vector Space scalar having ndims(⋅) = 0. So the outcome in the first line is 0, which is also of ndims zero. So map is a “closed” operator on the number field (is this the correct term?).

But this pattern is not preserved in the penultimate command, where it returns a Vector—having ndims(⋅) !== 0, hence the map is no longer a “closed” operator. (The last command is correct, though, in terms of preserving ndims)

Conclusion: preserving length is not enough:

julia> length(0) == length([0]) == 1
true

since clearly 0 is very different from [0]. map should also preserve ndims, Maybe also preserving axes whenever applicable:

julia> axes(0)
()

julia> axes([0])
(Base.OneTo(1),)

Is there anyone willing to make a related pull request to amend this somehow? Thanks very much.

PS The original link is here, wherein are some discussions.
By the way, I also found this (an 2018 link)[Inconsistent behavior of map].

I agree this is undesirable. map’s generic method should be returning a container of the same size as the input, so here the sensible generic fallback would have likely been an Array{Nvmber, 0}.

1 Like

The issue here is just that the default IteratorSize for any type is HasLength(). If you overload IteratorSize and size, map indeed returns a 0d array:

julia> Base.IteratorSize(::Nvmber) = Base.HasShape{0}()

julia> Base.size(::Nvmber) = ()

julia> map(_ -> Nvmber(), Nvmber())
0-dimensional Array{Nvmber, 0}:
Nvmber()
2 Likes

Ah of course, I always forget about this annoying situation with size / length being totally independant of the defaults for IteratorSize

1 Like

:smiling_face_with_tear:Well, this probably isn’t what an user want either. A 0d array is strange in practice. I guess just the scalar is better. I’ll have to admit that it’s somewhat inconsistent. But I think this special method
map(f, x::Number, ys::Number...) = f(x, ys...)
makes more sense. And it’s much more straightforward.

julia> map(_ -> Nvmber(), Nvmber())
Nvmber()

julia> map((_, _) -> Nvmber(), Nvmber(), Nvmber())
Nvmber()

resembling

julia> map(_ -> 2, 1)
2

julia> map((_, _) -> 2, 1, 3)
2

Anyway, thank you.

There are options for what to return for ()-shaped objects by default:

  • a 0d array
  • a Ref
  • a scalar

Out of them, a 0d array probably follows the principle of least astonishment, because for non-empty-shaped collections map returns a correspondingly-shaped array. A Ref would probably work just as good with less overhead, though.

A scalar, I think, does add inconsistency, and special-casing numbers breaks an invariant shape(map(f, x)) == shape(x) if f returns a collection.

1 Like

Numbers are indeed very special

julia> import JuMP

julia> x = JuMP.@variable(JuMP.Model()) # a decision scalar
_[1]

julia> x[]
ERROR: MethodError: no method matching getindex(::JuMP.VariableRef)

julia> 1[] # a scalar number supports this
1

julia> map(y -> y[], x)
ERROR: MethodError: no method matching getindex(::JuMP.VariableRef)

julia> map(y -> y[], 1)
1

Well, as long as I don’t use map on a zero-ndims object, I think the existing behavior is okay.
Maybe the behavior I want is broadcast.
Close this topic.