How to discourage macros: disable automerge of General registry PRs?

I don’t think this post really thought through the implications. Yes, macros can be abused. But what are the packages we’d be omitting here:

  1. Packages that implement domain-specific languages (JuMP.jl, ModelingToolkit.jl, Turing.jl, Catalyst.jl, etc.)
  2. Packages that implement performance optimizations via macros (LoopVectorization.jl, MuladdMacro.jl, etc.)
  3. Packages that use macros as small syntax sugar for users (Symbolics.jl, Tullio.jl, etc.)

If you couldn’t tell from the list, this rule would be omitting almost all of the most widely used packages in the Julia registry? Are we really going to basically remove the automatic merge for every major package because it’s possible to abuse macros?

I mean, yes I agree that you can misuse macros, but it’s very heavy-handed to then make almost every major package no longer get updates without manual intervention.

4 Likes
macro is_this_a_good_idea(x = "")
	"no"
end
1 Like

I’ll admit I failed to account for useful packages like LoopVectorization.jl while writing this post, however:

  1. Judging by GitHub - JuliaSIMD/LoopModels: "Full speed or nothing." - James Hetfield, and some of the warnings and limitations of LoopVectorization, the current implementation of LoopVectorization seems to be a limiting factor. So I think the macro-based approach may be a hack after all, despite how useful it was up to now?
  2. Symbolics.jl was actually one of the packages that prompted this post. Notice that I wrote “if the macro is part of the public API, the function that the macro wraps should also be public” in the OP above. Symbolics offers the @variables macro which is useful for interactive use, but there is no public API for using similar functionality as appropriate from noninteractive (package) code! There’s an internal function, Symbolics.variables, but even it only offers a semblance of the functionality of @variables, to quote its documentation:

Use @variables instead to create symbolic array variables (as opposed to array of variables).

One relevant issue:

Agreed. Also, a common trend in this discourse is that we forget the diversity of programmers wandering in here. Academic codes, domain-specific codes, scripting codes, production codes, package codes, etc.

Hence the: “Use it only if you reeeeeally know what you’re doing”. And consequently, status quo is fine.

Btw … hence the comparison I suggested about goto’s
And judging by some responses, I’d respectfully say that my point was quite misunderstood, which was not about the internals of goto - especially at assembly level !! :grimacing: - but more about its usage (which is arguably quite seldom … and should I remind that rare ≠ never ?).

Edit: typo and clarity

1 Like

Yes, this is naturally not something that is easily amenable to being a function. Which is why it’s documented that you do x = :somesymbol; y = only(@variables($x)) if you want to make a variable named somesymbol and save it at a Julia variable y. When things require knowledge of stuff like the current module, it’s not easy to write as a function.

2 Likes

A compiler based approach would be better, but it doesn’t exist. Removing packages that do exist for some future utopia that doesn’t exist yet is somewhat odd. When will it exist? Who knows. Will it ever? Who knows. But the macro works today.

3 Likes

A more direct solution would be to improve macros in Julia. For example, JuliaSyntax has some cool stuff that could help out with macro hygiene. It would probably be more helpful to make it easy to use them correctly than to flat out discourage them. Admittedly, It is a lot more work though

6 Likes

Yes, having it backed into the compiler is often preferred. As you say, macros work today and allow to explore possible solutions already. Even a half-backed solution now is certainly better than none.
A good solution might eventually make it into the compiler. Imho, the main advantage is that it becomes a language wide standard then and can be more deeply integrated, it might not be better in all respects and harder to change though (there have been some discussion around the iteration interface that for expands into). Even languages without macros evolve syntax-wise, it just takes much longer (Java took years to get for-each loops and lambdas eventually).

1 Like

:heart_eyes: this. I think there’s a lot to potentially be gained by having a closer look at langs that (seem to) have given macros more thought than Julia so far, e.g. Racket: Macros.

2 Likes

See `break` and `continue` out of multiple loops · Issue #5334 · JuliaLang/julia · GitHub and the Multibreak package, which incidentally exports a macro (and nothing else).

4 Likes

You list important packages. It would inform the discussion if we had examples of packages with overuse of macro definitions.

While OP and title proposed blocking, I propose(d) rather just delaying, and/or tagging “macros defined in package” (since that’s possible).

Code quality matters, but is often not exposed to the user. It is with macros; and all (exported/public) naming. I don’t want to raise the bar too high on registration, also a question what can be easily automatically checked. Whether a name/API is bad can’t be machine checked, unlike macro existence.

We want more registered packages, but not at all cost, at least not automerging quickly. I’ve blocked package registration on some grounds including being (almost) totally empty (and spam on one other case!).

The issue here may be overblown, so I’m not asking for anyone to code up the code needed to scan for macros and autoblock. At least without evidence needed. It then seem relatively easy if people agree to do this.

Indeed, if anyone actually had anything they could point to that indicates that macro usage in the package ecosystem is a problem in any way, then maybe there would have been a point in starting this thread.

8 Likes

As well as being used to break nested loops,
one of the common uses of gotos is to implement state machines.
State machines area really easy to implement with gotos.
For this reason you will often seen them in code for parsing. TimeZones.jl uses them for example in the code for parsing timezone specifications.

Features exist in the language for a reason.
They were implemented and included originally not by chance.
They took actual effort to implement.
And in cases like this it isn’s that our knowledge of how to structure programs has advanced that much in the past 10 years.
So its unlikely that things have been included in the language that are footguns without some cruicial need.
(there are a few things that are foot guns that do have a cruical need however)

5 Likes

Haha there’s someone who dislikes macros more than I do :slight_smile: I think they’re a bane in terms of having a true understanding of what’s going on in a given piece of code, even more so for new users. But I agree with others that blocking the packages would be too heavy handed.

I don’t think so… Some of my complaints with macros:

  • they make things look different from what they are (@m a+b might not call +)
  • they make it easy to mess with the caller’s workspace (e.g. defining or changing variables there)
  • they break referential transparency: @m(x) is not the same as @m(value of x)
  • macro implementations are much harder to read than functions

Some of these concerns can apply to functions (e.g. call(+, a, b) might not call +) but macros bring them to another level.

I love @show, @time and many other macros but they’re definitely not just like functions.

5 Likes

Macros already have a mechanism for discouraging their overuse and ensuring their usages are well-known: you gotta use that @ sigil.

In my view that strikes a good balance between the lispy traditions of having them appear exactly the same as normal functions vs. further limiting their capabilities or scope.

I’d be more inclined to agree with this idea if macros and functions were more easily conflated in the language. But they’re not — you know that there may be unusual dragons hiding behind the @, both when reading and writing code.

12 Likes

I can locally shadow or define + to do all sorts of things.

let (+) = (-)
     1 + 1
end

returns 0.

function foo()
    Main.x = 1
end

will rebind or define x in Main.

Macros are referentially transparent, it’s just that it’s input here is syntax, not runtime values.

That is,

var"@foo"(LineNumberNode(@__LINE__(), @__FILE__()), x)

is the same as

var"@foo"(LineNumberNode(@__LINE__(), @__FILE__()), value of x)

That’s only true if the reader isn’t familiar with the datastructures and algorithms associated with writing macros. If I tried to inspect a function involving a bunch of data structures and algorithms I wasn’t familiar with, I would also be confused.

4 Likes

As I said it’s not that functions cannot be abused, but that macros bring the problems to another level.

Interesting, but I think it’s missing the point. Say you’re reading a piece of code and stumble on a(f(x)). You can decompose the problem: first you understand what f(x) does, what value y it returns. Then you try to understand a(y). This is why referential transparency is great for readability. All I’m saying is that @a(f(x)) doesn’t have this nice property.

Thank god there is some referential transparency in the macro plumbing, thanks to the functions involved. Imagine if it was macros all the way down :slight_smile:

Well you don’t have to imagine, look at some random TeX code to see what it would look like.

There is a fundamental increase in complexity when you replace “code that does x” with “macro code that takes code and rewrites it into code that does x”. The macro version might be shorter, cleaner, but I’d like to see an example where it’s simpler (or as simple).

3 Likes
using TensorCast
list = [[1 2; 3 4] .* fill(k,2,2) for k in 1:8]

@cast colwise[y⊗x,i] := (list[i][x,y])^2
# vs
reshape(permutedims(stack((.^).(list, 2)), (2, 1, 3)), :, length(list))
# vs
out = similar(eltype(list), prod(size(list[1])), length(list))
for i in eachindex(list)
    elem = list[i]
    for x in axes(elem, 1)
        for y in axes(elem, 2)
            out[y + size(elem, 1)*(x-1), i] = (list[i][x,y]).^2
        end
    end
end
3 Likes

Of course calling a macro to do some work is simpler than writing the code that does the work. Same is true if you call a function to do the work. Here’s again the sentence before the part you quoted:

There is a fundamental increase in complexity when you replace “code that does x” with “macro code that takes code and rewrites it into code that does x”.

I’m talking from the perspective of the developer who must decide between writing a macro or not. Or the user who wants to understand what the code is doing, maybe to fix a bug.

So the right comparison is between

reshape(permutedims(stack((.^).(list, 2)), (2, 1, 3)), :, length(list))
# or
out = similar(eltype(list), prod(size(list[1])), length(list))
for i in eachindex(list)
    elem = list[i]
    for x in axes(elem, 1)
        for y in axes(elem, 2)
            out[y + size(elem, 1)*(x-1), i] = (list[i][x,y]).^2
        end
    end
end

and the macro implementation (according to @less @cast colwise[y⊗x,i] := (list[i][x,y])^2):

macro cast(exs...)
    call = CallInfo(__module__, __source__, TensorCast.unparse("@cast", exs...))
    _macro(exs...; call=call)
end

function _macro(exone, extwo=nothing, exthree=nothing; call::CallInfo=CallInfo(), dict=Dict())
    store = (dict=dict, assert=[], mustassert=[], seen=[], need=[], top=[], main=[])
    # TODO use OrderedDict() for main? To allow duplicate removal

    if Meta.isexpr(exone, :macrocall)
        # New style @cast @avx A[i] := B[i]
        string(exone.args[1]) in ("@lazy", "@strided", "@avx", "@avxt", "@turbo", "@tturbo") || throw(MacroError(
            "the macro $(exone.args[1]) isn't one of the ones this understands", call))
        push!(call.flags, Symbol(string(exone.args[1])[2:end]), :premacro)
        return _macro(exone.args[3:end]...; call=call, dict=dict)
    end

    if (:reduce in call.flags) || (:matmul in call.flags)
        # First the options:
        optionparse(exthree, store, call)
        # Then the LHS, to get canonical list of indices:
        canon, parsed = reduceparse(exone, extwo, store, call)
    elseif containsindexing(extwo) # @cast A[i,j] := softmax(j) B[i,j]
        push!(call.flags, :dimcast)
        optionparse(exthree, store, call)
        canon, parsed = reduceparse(exone, extwo, store, call)
    else
        # Simple @cast case:
        isnothing(exthree) || throw(MacroError("too many expressions for @cast: $exthree", call))
        optionparse(extwo, store, call)
        canon, parsed = castparse(exone, store, call)
    end

    # First pass over RHS just to read sizes, prewalk sees A[i][j] before A[i]
    MacroTools.prewalk(x -> rightsizes(x, store, call), parsed.right)

    # To look for recursion, we need another prewalk. To find naked indices, this one stops early:
    right2 = recursemacro(parsed.right, canon, store, call)

    # Third pass to standardise & then glue, postwalk sees A[i] before A[i][j]
    right3 = MacroTools.postwalk(x -> standardglue(x, canon, store, call), right2)
    right3 = checkallseen(right3, canon, store, call)

    if !(:matmul in call.flags)
        # Then finally broadcasting if necc (or just permutedims etc. if not):
        right4 = targetcast(right3, canon, store, call)
    else
        # or, for matmul, can I change just this? outputinplace is also going to need changing.
        right4 = matmultarget(right3, canon, parsed, store, call)
    end

    # Return to LHS, build up what it requested:
    if :inplace in call.flags
        rightlist = inplaceoutput(right4, canon, parsed, store, call)
    else
        right5 = newoutput(right4, canon, parsed, store, call)
        rightlist = [:( $(parsed.name) = $right5 )]
    end

    # Sew all these pieces into output:
    outex = quote end
    append!(outex.args, store.top)
    append!(outex.args, findsizes(store, call)) # this must be run after newoutput() etc.
    append!(outex.args, store.main)
    append!(outex.args, rightlist)

    if :recurse in call.flags
        return (name=parsed.name, ind=parsed.outer, scalar=(:scalar in call.flags), steps=outex.args)
    else
        return esc(outex) # could use MacroTools.unblock()?
    end
end

which generates the following (according to @macroexpand):

quote
    #= /home/j/.julia/packages/TensorCast/mQB8h/src/macro.jl:209 =#
    if $(Expr(:boundscheck))
        list isa Tuple || (Base.ndims(list) == 1 || Base.throw(ArgumentError("expected a vector or tuple list[i]")))
    end
    local (ax_i, ax_x, ax_y) = (Base.axes(list, 1), Base.axes(first(list), 1), Base.axes(first(list), 2))
    local var"##nobroadcast#294" = TensorCast.transmute(TensorCast.lazystack(list), Base.Val((2, 1, 3)))
    colwise = Base.reshape((^).(var"##nobroadcast#294", 2), (TensorCast.star(ax_y, ax_x), ax_i))
end

I think it’s a good example of macro pros and cons. In this case:

Pros:

  • Super easy to use and powerful
  • A single implementation covers a gazillion of cases

Cons

  • Monumental increase in complexity. To verify the implementation one must understand the huge macro implementation and the regular code it generates.
2 Likes

Ok, I see where you’re coming from. Indeed macros can be harder to write and understand than functions. In the end, providing them is a trade-off between how much is saved by better syntax, convenience etc compared to the effort in implementation. Compared to functions, the bar on macros is often higher, i.e., they should give you more benefit before you decide to write them. Nevertheless, especially in libraries some well-chosen macros can be very nice (and TensorCast is certainly such an example).

Much of this also applies to functional APIs especially if backed by huge frameworks (fortunately not very common in Julia). The main question is again the trade-off between ease of use and difficulty of implementation. Further, consider where to stop to “verify the implementation”, i.e., let’s say you are using

  1. a function from Base: Probably considered correct, i.e., no need to look at or verify its implementation (even though its comparably easy to read its source code in Julia)
  2. a function from some third-party library: Probably depends on judging its quality and whether I try some weird things needing knowledge of the internals.
  3. a macro from some library: Similar judgement, but for a fair comparison to 2, I should also judge the effect on my user code, i.e., let’s say I need to reshape arrays several times in my code and read it some month later. Would I want to read @cast lines or the equivalent functional code, i.e., basically corresponding to the macro expansion?

Your mileage may vary here, but depending on the use case I might not need to (fully) understand the macro implementation to make good use of it. In the end, it’s always a trade-off and I agree that macros should be used sparingly – especially if a function could achieve a similar effect.
On the other hand, well chosen macros or similar syntactic sugar can lead to very readable code – just consider the do-Notation which broadens the scope of where functions are used in Julia just because it reads very well (quite similar to the with-Notation in Python). It’s build in, but nevertheless syntax I need to understand – and if it would not be build in, I could write a macro similarly broadening the scope of the language.

2 Likes