JuliaLang / julia

The Julia Programming Language
https://julialang.org/
MIT License
45.44k stars 5.46k forks source link

Feature request: @fill macro for expressions that need to be evaluated for every entry #38845

Open jkrumbiegel opened 3 years ago

jkrumbiegel commented 3 years ago

I think the fill function could use an accompanying @fill macro, because I relatively often want to fill an array of a specific size with the result of an expression evaluated for every entry.

This came up recently in a Discourse post, where someone was suprised fill(Int[], 5) gives a vector with five times the same empty Int Vector.

One use-case I often encounter is in MakieLayout, where I'd like to generate a matrix of LAxis objects. They are obviously not all supposed to be the same. So a common thing to write is:

axs = [LAxis(scene) for _ in 1:4, _ in 1:3]
# or maybe
axs = [LAxis(scene) for _ in CartesianIndices((4, 3))]

I propose the syntax:

axs = @fill(LAxis(scene), 4, 3)
# or just like fill supports tuples
axs = @fill(LAxis(scene), (4, 3))

Here is a preliminary implementation:

macro fill(exp, args...)
    :([$(esc(exp)) for _ in fill_iterator(($(esc.(args)...)))])
end

function fill_iterator(ii::Integer...)
    CartesianIndices((ii...,))
end

function fill_iterator(t::Tuple)
    CartesianIndices(t)
end
rfourquet commented 3 years ago

16769 is very related, it motivates me to revive it as I also need that once in a while. I will update that PR with a function fill(filler::Function, ...) which fills A with repeated invocations of filler(), i.e. your proposed @fill(val, ...) would be equivalent to fill(()->val, ...).

thofma commented 3 years ago

Yes, this functionality would be great. But why call it @fill? How should one remember or notice that the macro version @fill does something different to fill? That invites only bugs that are hard to track down. Macro versions of functions usually do the same without subtle differences (see @code_warntype vs. code_warntype).

P.S.: Why does it need to be a macro at all?

mcabbott commented 3 years ago

@thofma By the time fill is called, its arguments have already been evaluated, just once. Although I agree that @fill doesn't fit the pattern elsewhere very closely. It's possible that fill could call copy on them, which in cases like fill(Int[], 5) would be the same as the macro version, although fill(rand(), 5) would differ.

One problem with fill(::Function, size...) is that not every callable is a function, although I see that for example get!(default::Union{Function, Type}, h::Dict{K,V}, key::K) doesn't take f::Any. Another is that currently this would make an array of functions, even if that's not a very useful thing.

thchr commented 3 years ago

I think you were trying to explain this above @mcabbott , but what would a macro version be able to do that a higher-order function version, taking a thunk (e.g. () -> LAxis(scene)), couldn't? Seems to me that every case could be covered by letting fill(f, args...) take a thunk f = ()-> ... (or even letting f take, say, the array index at that element; but then it seems very close in spirit to something like map, albeit with array output).

johnnychen94 commented 3 years ago

As @thchr suggested(If I understand your words correctly), an array version ntuple meets the requirements:

julia> function mapfill(f, inds...)
           map(CartesianIndices(inds)) do idx
               f(idx.I...)
           end
       end
mapfill (generic function with 1 methods)

julia> mapfill((i, j)->Int[], 2, 3)
2×3 Array{Array{Int64,1},2}:
 []  []  []
 []  []  []
mcabbott commented 3 years ago

That wasn't so clear, sorry. I agree that a version which takes a function would be pretty natural, and would serve the same role as the macro proposal.

But it seems awkward to make this a method of fill, since right now fill(() -> rand(), 3) does do something, even if it's not so useful. I can't think why you'd want an array of the same function, but maybe someone does. If you decided it was OK to break that, it would still need to be fill(::Function, size...) rather than accepting any other callable struct. Unlike say map, but like get! which similarly takes a zero-argument function just as a way to delay execution, so perhaps that's not so bad. (And IIRC this get! replaced a macro @get!.)

Another possibility is to make a method of collect. Right now collect(Float64, 1:3) works with types, but not with functions. Base.collect(f, xs...) = map(Base.splat(f), Iterators.product(xs...)) would let you write collect((_...) -> LAxis(scene), 1:3, 1:4).

jkrumbiegel commented 3 years ago

The point of my proposal is mostly that it's quite convenient to have a short syntax for this. Of course you can do the same with higher order functions, or list comprehensions as I wrote in my post above. I personally think @fill is fine, because obviously the macro version would do something related to but different from the non-macro function. I find the difference similar to edit vs @edit