Adomas Baliuka
Adomas Baliuka

Reputation: 1620

Making a macro to mark functions as deprecated

In my earlier question I found out that the standard library (Julia v1.5) macro @deprecate is used to replace functions by others.

I want to make a macro mark_deprecated that has the following effect when applied to a function:

  1. Print a customizable deprecation warning when the target function is called (if possible, only for the first time it is called).
  2. Modify the function's documentation (viewable as julia>? function_name) to also include the deprecation warning.

Of course many other convenient options may be included later, such as the ability to specify a replacement function, an option to produce errors instead of warnings, etc.

I am mainly doing this as an exercise in Julia metaprogramming, in which I have zero experience so far (slightly worried this could be too hard as a first task).

Trying to understand @deprecate

As a first step, I looked at the current Standard Library @deprecate macro. It goes as follows:

# julia v1.5
# quoted from deprecated.jl, included by Base.jl

macro deprecate(old, new, ex=true)
    meta = Expr(:meta, :noinline)
    if isa(old, Symbol)
        oldname = Expr(:quote, old)
        newname = Expr(:quote, new)
        Expr(:toplevel,
            ex ? Expr(:export, esc(old)) : nothing,
            :(function $(esc(old))(args...)
                  $meta
                  depwarn($"`$old` is deprecated, use `$new` instead.", Core.Typeof($(esc(old))).name.mt.name)
                  $(esc(new))(args...)
              end))
    elseif isa(old, Expr) && (old.head === :call || old.head === :where)
        remove_linenums!(new)
        oldcall = sprint(show_unquoted, old)
        newcall = sprint(show_unquoted, new)
        # if old.head is a :where, step down one level to the :call to avoid code duplication below
        callexpr = old.head === :call ? old : old.args[1]
        if callexpr.head === :call
            if isa(callexpr.args[1], Symbol)
                oldsym = callexpr.args[1]::Symbol
            elseif isa(callexpr.args[1], Expr) && callexpr.args[1].head === :curly
                oldsym = callexpr.args[1].args[1]::Symbol
            else
                error("invalid usage of @deprecate")
            end
        else
            error("invalid usage of @deprecate")
        end
        Expr(:toplevel,
            ex ? Expr(:export, esc(oldsym)) : nothing,
            :($(esc(old)) = begin
                  $meta
                  depwarn($"`$oldcall` is deprecated, use `$newcall` instead.", Core.Typeof($(esc(oldsym))).name.mt.name)
                  $(esc(new))
              end))
    else
        error("invalid usage of @deprecate")
    end
end

My attempts at understanding this thing (NO NEED TO READ IF YOU UNDERSTAND THE MACRO):

Towards my macro

Trying to plagiarize the above:

# julia v1.5
module MarkDeprecated

using Markdown
import Base.show_unquoted, Base.remove_linenums!


"""
    @mark_deprecated old msg 
Mark method `old` as deprecated. 
Print given `msg` on method call and prepend `msg` to the method's documentation.
        MACRO IS UNFINISHED AND NOT WORKING!!!!!
"""
macro mark_deprecated(old, msg="Default deprecation warning.", new=:())
    meta = Expr(:meta, :noinline)
    if isa(old, Symbol)
        # if called with only function symbol, e.g. f, declare method f(args...)
        Expr(:toplevel,
            :(
                @doc(  # This syntax is riddiculous, right?!?
                    "$(Markdown.MD($"`$old` is deprecated, use `$new` instead.", 
                                @doc($(esc(old)))))",
                    function $(esc(old))(args...)
                    $meta
                    warn_deprecated($"`$old` is deprecated, use `$new` instead.", 
                            Core.Typeof($(esc(old))).name.mt.name)
                    $(esc(new))(args...)
                    end
                )
            )
        )
    elseif isa(old, Expr) && (old.head === :call || old.head === :where)
        # if called with a "call", e.g. f(a::Int), or with where, e.g. f(a:A) where A <: Int,
        # try to redeclare that method
        error("not implemented yet.")
        remove_linenums!(new)
        # if old.head is a :where, step down one level to the :call to avoid code duplication below
        callexpr = old.head === :call ? old : old.args[1]
        if callexpr.head === :call
            if isa(callexpr.args[1], Symbol)
                oldsym = callexpr.args[1]::Symbol
            elseif isa(callexpr.args[1], Expr) && callexpr.args[1].head === :curly
                oldsym = callexpr.args[1].args[1]::Symbol
            else
                error("invalid usage of @mark_deprecated")
            end
        else
            error("invalid usage of @mark_deprecated")
        end
        Expr(:toplevel,
            :($(esc(old)) = begin
            $meta
            warn_deprecated($"`$oldcall` is deprecated, use `$newcall` instead.", 
                    Core.Typeof($(esc(oldsym))).name.mt.name)
            $(esc(old)) # TODO: this replaces the deprecated function!!!
        end))
    else
        error("invalid usage of @mark_deprecated")
    end
end


function warn_deprecated(msg, funcsym)
    @warn """
            Warning! Using deprecated symbol $funcsym.
            $msg
            """
end

end # Module MarkDeprecated

For testing:

module Testing

import ..MarkDeprecated  # (if in the same file)

a(x) = "Old behavior"
MarkDeprecated.@mark_deprecated a "Message" print

a("New behavior?")

end

Problems

I so far failed to do any of the two things I wanted:

  1. How do I deal with a situation when the caller doesn't import Markdown, which I use to concatenate the docstrings? (EDIT: Apparantly this is not a problem? For some reason the modification seems to work despite the module Markdown not being imported in the Testing module. I don't fully understand why though. It's hard to follow where each part of the macro generated code is executed...)
  2. How do I actually avoid replacing the function? Calling it from inside itself creates an infinite loop. I basically need a Python-style decorator? Perhaps the way to do it is to only allow adding the @mark_deprecated to the actual function definition? (such a macro would actually be what I was expecting to find in the standard library and just use before I fell down this rabbithole)
  3. The macro (which is also true for @deprecate) does not affect the method a(x) in my example since it only creates a method with signature a(args...), which has lower priority for one argument calls, when the macro is called on the function symbol alone. While not obvious to me, this seems to be desired behaviour for @deprecate. However, is it possible to default application of the macro to the bare function symbol to deprecating all methods?

Upvotes: 2

Views: 301

Answers (1)

I think what you want to achieve is not the same thing as what Base.@deprecate does. If I understand correctly:

  • you don't want the macro to create a definition for the deprecated method; you want rather to annotate a handwritten definition
  • you want to modify docstrings, which @deprecate does not

And since you're doing this as an exercise to learn metaprogramming, maybe you could try writing your own macro step by step, rather than understanding how Base.@deprecate works and trying to adapt it.

As for your specific questions:

1. How do I deal with a situation when the caller doesn't import Markdown?

Maybe the following example helps explaining how things work:

module MyModule

# Markdown.MD, Markdown.Paragraph and msg are only available from this module
import Markdown
msg(name) = "Hello $name"

macro greet(name)
    quote
        # function names (e.g. Markdown.MD or msg) are interpolated
        # => evaluated at macro expansion time in the scope of the macro itself
        # => refer to functions available from within the module
        $(Markdown.MD)($(Markdown.Paragraph)($msg($name)))

        # (But these functions are not called at macro expansion time)
    end
end
end

See in particular how msg correctly refers to Main.MyModule.msg, which is how you have to call it from the "outside" context:

julia> @macroexpand MyModule.@greet "John"
quote
    #= REPL[8]:8 =#
    (Markdown.MD)((Markdown.Paragraph)((Main.MyModule.msg)("John")))
end

julia> MyModule.@greet "John"
  Hello John

2. Perhaps the way to do it is to only allow adding the @mark_deprecated to the actual function definition?

Yes, that is what I would do.

3. Is it possible to default application of the macro to the bare function symbol to deprecating all methods?

I guess it would technically be possible to deprecate all methods of a given function... or at least all methods that exist at the time when your deprecation code runs. But what about methods that would be defined afterwards? I personally would not go that way, marking only method definitions.



Maybe something like this could be a stub to be used as a starting point for a more complex macro doing precisely what you want:

module MarkDeprecate
using Markdown
using MacroTools

function mark_docstring(docstring, message)
    push!(docstring,
          Markdown.Paragraph("Warning: this method is deprecated! $message"))
    docstring
end

function warn_if_necessary(message)
    @warn "This method is deprecated! $message"
end

macro mark_deprecate(msg, expr)
    fundef = splitdef(expr)
    prototype = :($(fundef[:name])($(fundef[:args]...);
                                   $(fundef[:kwargs]...)) where {$(fundef[:whereparams]...)})

    fundef[:body] = quote
        $warn_if_necessary($msg)
        $(fundef[:body])
    end

    quote
        Base.@__doc__ $(esc(MacroTools.combinedef(fundef)))
        Base.@doc $mark_docstring(@doc($prototype), $msg) $prototype
    end
end
end
julia> """
           bar(x::Number)
       
       some help
       """
       MarkDeprecate.@mark_deprecate "Use foo instead" function bar(x::Number)
           42
       end
bar

julia> """
           bar(s::String)
       
       This one is not deprecated
       """
       bar(s::String) = "not deprecated"
bar

julia> methods(bar)
# 2 methods for generic function "bar":
[1] bar(s::String) in Main at REPL[4]:6
[2] bar(x::Number) in Main at REPL[1]:23

julia> @doc(bar)
  bar(x::Number)

  some help

  Warning: this method is deprecated! Use foo instead

  bar(s::String)

  This one is not deprecated

julia> bar("hello")
"not deprecated"

julia> bar(5)
┌ Warning: This method is deprecated! Use foo instead
└ @ Main.MarkDeprecate REPL[1]:12
42

Upvotes: 2

Related Questions