v2.0.0
DynamicExpressions.jl v2.0 introduces support for n-arity operators (nodes with arbitrary numbers of children), which required some breaking changes to implement. This guide will help you migrate your code from v1.x to v2.0.
Summary
- Types
Node{T}
is nowNode{T,D}
whereD
is the maximum degreeAbstractExpressionNode{T}
is nowAbstractExpressionNode{T,D}
AbstractNode
is nowAbstractNode{D}
- Before,
Node{T}
had fieldsl::Node{T}
andr::Node{T}
. Now, the type isNode{T,D}
, and it has the fieldchildren::NTuple{D,Nullable{Node{T,D}}}
.
- Accessors
- You can now access children by index with
get_child(tree, i)
tree.l
can now be written asget_child(tree, 1)
tree.r
can now be written asget_child(tree, 2)
- note: you can access multiple children with
get_children(tree, Val(degree))
- You can now set children by index with
set_child!(tree, child, i)
tree.l = child
should now be written asset_child!(tree, child, 1)
tree.r = child
should now be written asset_child!(tree, child, 2)
- note: you can set multiple children with
set_children!(tree, children)
- You can now access children by index with
- Constructors
Node{T}(; op=1, l=x)
should now be written asNode{T}(; op=1, children=(x,))
Node{T}(; op=1, l=x, r=y)
should now be written asNode{T}(; op=1, children=(x, y))
- You may now use
Node{T,D}(; op=1, children=(x,))
to specify degree other than the default of 2.
- OperatorEnum
OperatorEnum
(andGenericOperatorEnum
) now uses a singleops
field: this is a tuple of tuples, indexed by arity.operators.unaops
is now written asoperators.ops[1]
, andoperators.binops
is now written asoperators.ops[2]
.
OperatorEnum(binary_operators=(+, -, *), unary_operators=(sin, cos))
can now be written asOperatorEnum(2 => (+, -, *), 1 => (sin, cos))
- This API permits higher-arity operators:
OperatorEnum(1 => (sin, cos), 2 => (+, -, *), 3 => (fma, max))
.
- This API permits higher-arity operators:
Breaking Changes
The main breaking change is that Node{T}
is now Node{T,D}
where D
is the maximum degree of any possible node in the tree. node.degree
is still the same as before, and is such that node.degree <= D
.
Similarly, AbstractExpressionNode{T}
is now AbstractExpressionNode{T,D}
, and AbstractNode
is now AbstractNode{D}
.
Before, Node{T}
had fields l::Node{T}
and r::Node{T}
. Now, it has a single combined field children::NTuple{D,Nullable{Node{T,D}}}
. This is a tuple of wrapped node objects, which should be accessed with get_child(tree, i)
and set with set_child!(tree, child, i)
. However, the old getters and setters will still function for binary trees (.l
and .r
).
You may now use Node{T,D}(; op=1, children=(x,))
to specify degree other than the default of 2. However, the default Node{T}(; op=1, children=(x,))
is still available and will result in type Node{T,2}
.
Necessary Changes to Your Code
The main breaking change that requires some modifications is patterns that explicitly match tree.degree
in conditional logic. The tree.degree == 0
branch can be left alone, but higher arity nodes should be generalized.
For code like this:
# This pattern ONLY works for binary trees (degree ≤ 2)
if tree.degree == 0
# leaf node
elseif tree.degree == 1
# unary operator
else # tree.degree == 2 # <-- This violates the assumption in 2.0
# binary operator
end
You have two options for upgrading
-
Constrain your type signatures: Use
::AbstractExpressionNode{T,2}
to only accept binary trees, and refuse higher-arity nodesfunction my_function(tree::AbstractExpressionNode{T,2}) where T if tree.degree == 0 # leaf elseif tree.degree == 1 # unary else # tree.degree == 2, guaranteed # binary end end
-
Rewrite your code to be more generic. (Note that for recursive algorithms, you can often do things with a
tree_mapreduce
, which already handles the general case.)# 2: Handle arbitrary arity function my_function(tree::AbstractExpressionNode{T}) where T if tree.degree == 0 # leaf else # higher arity deg = tree.degree for i in 1:deg child = get_child(tree, i) # process child... end end end
However, normally what is done internally for max efficiency for the general approach is to use patterns like:
@generated function my_function(tree::AbstractExpressionNode{T,D}) where {T,D} quote deg = tree.degree deg == 0 && process_leaf(tree) Base.Cartesian.@nif( $deg, i -> i == deg, i -> let children = get_children(tree, Val(i)) # Now, `children` is a type-stable tuple of children end, ) end end
Note that the
@generated
is needed to passD
to the Cartesian macro.
Property Access (Non-Breaking)
Note: .l
and .r
property access still work and will continue to be supported on types with D == 2
. However, the generic accessors are more flexible, so upgrading to them is recommended.
# old_child = tree.l
old_child = get_child(tree, 1)
# tree.r = new_child
set_child!(tree, new_child, 2)
This lets you write code that prescribes arbitrary arity.
Node Construction (Non-Breaking)
For binary trees, you can still use the syntax:
x = Node{Float64}(; feature=1)
tree = Node{Float64}(; op=1, children=(x,))
For higher-arity trees, you may pass D
to specify the maximum degree in the tree:
x = Node{Float64,3}(; feature=1)
y = Node{Float64,3}(; feature=2)
z = Node{Float64,3}(; feature=3)
tree = Node{Float64,3}(; op=1, children=(x, y, z))
OperatorEnum redesign (Non-Breaking)
OperatorEnum
(and GenericOperatorEnum
) now uses a single ops
field: this is a tuple of tuples, indexed by arity. operators.unaops
is now written as operators.ops[1]
, and operators.binops
is now written as operators.ops[2]
.
However, the properties are aliased, so the old syntax will still work.
Along with this, there is a new API for constructing OperatorEnum
s:
# operators = OperatorEnum(binary_operators=(+, -, *), unary_operators=(sin, cos)) # old
operators = OperatorEnum(2 => (+, -, *), 1 => (sin, cos))
This API permits higher-arity operators:
operators = OperatorEnum(1 => (sin, cos), 2 => (+, -, *), 3 => (fma, max))
(Note that the order you pass the pairs is not important.)
Full Changelog: v1.10.3...v2.0.0