Supported types and dispatch in Symbolics
There is a tension between types as a representation of expression trees and types that are a subtype of types already present in Julia.
We want to be able to deal with expression trees in a unified way and not constrain expression trees themselves to be under an abstract type in Julia's type hierarchy. (For example, if we said that all expression trees are subtype of Real, then we couldn't represent array operations using the same expression tree.). But we also want to be able to pass in expression trees into places in existing code that accept Real values.
We accomplish this by wrapping expression trees in a simple wrapper type which is a subtype of our desired abstract type. For example, we wrap expression trees in the type Num which is a subtype of Real to make it behave like a Real number.
The methods on Num objects are forwarded to the wrapped expression tree. And care is taken so that an expression tree never internally contains Num – this is both for performance and separation of concerns.
User-facing APIs in Symbolics always take wrapped objects like Num, they are then internally unwrapped for expression tree manipulation.
Due to it requiring such wrappers, we only fully support a limited number of types as both the types of expression trees and the type as Julia sees them.
These types are
- Real numbers (wrapped using
Num) - complex numbers (stored as
Complex{Num}whereComplexis from Base Julia) - arrays of Real and complex numbers (wrapped using
Arr, soArr{Num}orArr{Complex{Num}})
@variables and types
Use the syntax @variables x::T to create a symbol named x of symbolic type T. If T is a subtype of any of the above listed types which support a wrapper, the resulting variable will be wrapped in that type. As seen in the examples below, x,z,X,Z all have a suitable wrapper type. Hence, their types are shown. However, s being of symbolic type String does not have a corresponding wrapper supported by Symbolics, and hence, it returns a Sym{String} object. This is the trivial expression tree of a single variable without a wrapper, and is not a subtype of String or AbstractString.
using Symbolics
@variables x::Real z::Complex{Real} (X::Real)[1:10, 1:10] (Z::Complex{Real})[1:10] s::String5-element Vector{Any}:
x
z
X[1:10,1:10]
Z[1:10]
stypeof(x)Numtypeof(z)Complex{Num}typeof(X)Symbolics.Arr{Num, 2}typeof(Z)Symbolics.Arr{Complex{Num}, 1}typeof(s)SymbolicUtils.BasicSymbolicImpl.var"typeof(BasicSymbolicImpl)"{SymReal}Type Wrapper API
Symbolics.wrap — Function
wrap(x) -> Any
Wrap the symbolic or non-symbolic value x in the appropriate wrapper type.
SymbolicUtils.unwrap — Function
unwrap(x)
Return the inner Symbolic wrapped in a non-symbolic subtype. Defaults to returning the input as-is.
Defining your own wrapper
A package that introduces a new symbolic type registers a wrapper for it with @symbolic_wrap, and then uses @wrapped to make its functions accept both the wrapper and raw expression trees in place of the concrete type.
Symbolics.@symbolic_wrap — Macro
@symbolic_wrap struct W <: T
...
endDefine W as the symbolic wrapper type for the symbolic type T, and register the correspondence between the two with Symbolics.
Symbolic expressions are stored as untyped expression trees (BasicSymbolic), but Julia code dispatches on types: a function written for Real will not accept an expression tree. Symbolics bridges the two by wrapping an expression tree in a struct that subtypes the type the expression stands for. Num <: Real is the built-in example — it holds an expression tree whose symtype is Real and can therefore be passed anywhere a Real is expected. @symbolic_wrap is how a package declares its own such pairing, so that Symbolics knows to wrap results of that symbolic type in W and to unwrap W back to the expression tree at the boundaries.
The macro takes a struct definition whose supertype is the symbolic type being wrapped. It emits the struct unchanged, plus the trait methods that tie W and T together: Symbolics.has_symwrapper(::Type{<:T}), Symbolics.wrapper_type(::Type{<:T}) = W, Symbolics.is_wrapper_type(::Type{<:W}), Symbolics.wraps_type(::Type{W}) = T and Symbolics.iswrapped(::W). Once those exist, Symbolics.wrap maps a value of symbolic type T to a W, Symbolics.unwrap maps it back, @wrapped generates wrapper-accepting methods, and @register_symbolic knows W counts as symbolic.
Two things are the caller's responsibility. W must be constructible from the value it wraps, because that is how Symbolics.wrap builds it, and a Symbolics.unwrap method must be defined for W to get the value back out.
The registration is on the unparameterized supertype, so Symbolics.wrapper_type(T{Int}) also returns W; if a parameterized wrapper is wanted there, add the method by hand.
Only one wrapper may be registered per symbolic type, and Real is already taken by Num. @symbolic_wrap is therefore for introducing wrappers over new symbolic types, not for replacing the built-in ones.
Example
using Symbolics
abstract type AbstractFoo{T} end
struct Foo{T} <: AbstractFoo{T} end
@symbolic_wrap struct FooWrap{T} <: AbstractFoo{T}
val::Foo{T}
end
Symbolics.unwrap(r::FooWrap) = r.val
wrap(Foo{Int}()) # FooWrap{Int}
unwrap(wrap(Foo{Int}())) # Foo{Int}With that in place, @wrapped can generate methods that accept FooWrap wherever they declare an AbstractFoo argument.
See also: @wrapped, Symbolics.wrap, Symbolics.unwrap.
Symbolics.@wrapped — Macro
@wrapped function f(x::T, ...) ... end
@wrapped f(x::T, ...) = ...
@wrapped function f(x::T, ...) ... end falseGiven one function definition written against concrete types, generate the additional methods that accept symbolic expressions and wrapper types in those argument positions.
A function written as f(x::Real) will not accept a Num, because Num holds an expression tree rather than a number, and will not accept a raw BasicSymbolic either. Writing the symbolic methods by hand means one method per combination of "concrete / symbolic / wrapped" across all arguments, each of which has to unwrap its wrapper arguments, call the body, and re-wrap the result. @wrapped writes that combinatorial expansion for you.
The body is emitted once under a private name, and one method of f is emitted per element of the product of the type options for each argument. For an argument annotated ::T the options are T itself, a symbolic expression whose symtype is T, and — when T has a wrapper registered via @symbolic_wrap — that wrapper type. When T is an array type, array-of-symbolic and array-of-wrapper options are added as well. Unannotated arguments are left as Any.
The all-concrete method is deliberately not emitted: @wrapped function f(x::Real) does not define f(::Real). That method is assumed to already exist outside Symbolics (often it is the very Base function being extended), and emitting it would be piracy. Consequently f must have a non-symbolic definition of its own if it is to be called on plain values.
In each generated method, wrapper-typed arguments are unwrapped before the body runs, and the result is re-wrapped with Symbolics.wrap whenever at least one argument was wrapped — so passing Nums in gets a Num back out, while passing raw BasicSymbolics in returns a raw expression. Symbolic arguments additionally get an @assert that their symtype matches the declared annotation, which turns a type mismatch into an error at the call rather than a wrong answer downstream.
Keyword arguments are forwarded as declared and are not expanded over; only positional arguments participate in the product.
The optional trailing argument (default true) controls whether array-typed arguments are expanded over their symbolic-array and wrapped-array options. Pass false to suppress that, which is useful when the method is meant to see the container itself rather than a symbolic array.
Example
Continuing the wrapper from @symbolic_wrap:
@wrapped function foo(f::AbstractFoo, x::Real)
x > 1 ? Foo{Int}() : 0
end
applicable(foo, Foo{Int}(), 2) # false: the all-concrete method is not emitted
applicable(foo, Foo{Int}(), wrap(2)) # true
applicable(foo, wrap(Foo{Int}()), 2) # true
applicable(foo, wrap(Foo{Int}()), wrap(2)) # trueSee also: @symbolic_wrap, Symbolics.wrap, Symbolics.unwrap.