Design Pattern

Design patterns aim at providing reusable solutions for solving the challenges in the process of software development. The ultimate goal of design patterns is to avoid reinventing the wheels and making software flexible and resilient to change. Design patterns are neither concrete algorithms, nor programming templates, but ways of thinking. They are not always necessary if you can come up with very simple designs, which are actually more preferable in practice. Rather, they are "rules of thumb" that facilitates you when you have a hard time how to design the structure of your codes.

We strive to make ADCME easily maintainable and extendable by using well-established design patterns for some design decisions. In this section, we describe some design patterns that are useful for programming ADCME.

Strategy Pattern

The strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. The strategy pattern makes programmers wrap algorithms that are subject to frequent changes (e.g., extending) as interfaces instead of concrete implementations. For example, in ADCME, FlowOp is implemented using the strategy pattern.

When you want to create a flow-based model, the structure NormalizingFlow has a method that performs a sequence of forward operations. The forward operations might have different combinations, which results in a large number of different normalizing flows. If we define a different normalizing flow structure for a different combination, there will be exponentially many such structures. Instead of defining a separate forward method for each different normalizing flow, we define an interface FlowOp, which has a forward method.

The interface is implemented with many concrete structures, which are called algorithms in the strategy pattern. These concrete FlowOps, such as SlowMAF and MAF, have their specific forward implementations. Therefore, the system become easily extendable. When we have a new algorithm, we only need to add a new FlowOp instead of modifying NormalizingFlow.

Adaptor Pattern

The adapter pattern converts the interface of a structure into another interface users expect. It is very useful to unify the APIs and reuses the existing functions, and thus reliefs users from memorizing many new functions. The typical structure of an adaptor has the form

struct Adaptor <: AbstractNewComponent
    o::LegacyComponent
    function new_do_this(adaptor, x)
        old_do_this(adaptor.o, x)
    end
    ...
end 

Here users work with AbstractNewComponent, whose concrete types implement a function new_do_this. However, we have a structure of type LegacyComponent, which has a function old_do_this. An adaptor pattern is used to match an old function call old_do_this to new_do_this in the new system.

An example of adaptor pattern is SparseTensor, which wraps a PyObject. The operations on the SparseTensor is propagated to the PyObject, and therefore users can think in terms of the new SparseTensor data type.

Observer Pattern

The observer pattern define a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically. The basic pattern is

Subject

struct Subject
    o # Observer 
    state
    function update(s)
        # some operations on `s.state`
    end
    function notify(s)
        # some operations on `s.o`
    end
end

Observer

struct Observer
    subjects::Array{Subject}
    function update(o)
        for s in o.subjects
            update(s)
        end
    end
end

For example, the commitHistory function in NNFEM.jl uses the observer pattern to update states from Domain, to Element, and finally to Material.

Decorator Pattern

The decorator pattern attaches additional responsibilities to an object dynamically. Decorator patterns are very similar to adaptor patterns. The difference is that the input and output types of the decorator pattern are the same. For example, if the input is a SparseTensor, the output should also be a SparseTensor. The adaptor pattern converts PyObject to SparseTensor. Another difference is that the decorator pattern usually does not change the methods and fields of the structure. For example,

struct Juice
    cost::Float64 
end

function add_one_dollar(j::Juice)
    Juice(j.cost+1)
end

Then add_one_dollar(j) is still a Juice structure but the cost is increased by 1. You can also compose multiple add_one_dollar:

add_one_dollar(add_one_dollar(j))

In Julia, this can be done elegantly using macros. We do not discuss macros in this section but leave it to another section on macros.

Iterator Pattern

Iterator patterns provide a way to access the elements of an aggregate object sequentially without exposing its underlying representation. Julia has built-in iterator support. The in keyword loops through iterations and collect collects all the entries in the iterator. In Julia, to understand iteration, remember that the following code

for i in x
    # stuff
end

is a shorthand for writing

it = iterate(x)
while it !== nothing
    i, state = it
    # stuff
    it = iterate(x, state)
end

Therefore, we only need to implement iterate

iterate(iter [, state]) -> Union{Nothing, Tuple{Any, Any}}

Factory Pattern

The factory pattern defines an interface for creating an object, but lets subclasses decide which class to instantiate. Simply put, we define a function that returns a specific structure

function factory(s::String)
    if s=="StructA"
        return StructA()
    elseif s=="StructB"
        return StructB()
    elseif s=="StructC"
        return StructC()
    else
        error(ArgumentError("$s is not understood"))
    end
end

For example, FiniteStrainContinuum in NNFEM.jl has a constructor

function FiniteStrainContinuum(coords::Array{Float64}, elnodes::Array{Int64}, props::Dict{String, Any}, ngp::Int64=2)
    eledim = 2
    dhdx, weights, hs = get2DElemShapeData( coords, ngp )
    nGauss = length(weights)
    name = props["name"]
    if name=="PlaneStrain"
        mat = [PlaneStrain(props) for i = 1:nGauss]
    elseif name=="Scalar1D"
        mat = [Scalar1D(props) for i = 1:nGauss]
    elseif name=="PlaneStress"
        mat = [PlaneStress(props) for i = 1:nGauss]
    elseif name=="PlaneStressPlasticity"
        mat = [PlaneStressPlasticity(props) for i = 1:nGauss]
    elseif name=="PlaneStrainViscoelasticityProny"
        mat = [PlaneStrainViscoelasticityProny(props) for i = 1:nGauss]
    elseif name=="PlaneStressViscoelasticityProny"
        mat = [PlaneStressViscoelasticityProny(props) for i = 1:nGauss]
    elseif name=="PlaneStressIncompressibleRivlinSaunders"
        mat = [PlaneStressIncompressibleRivlinSaunders(props) for i = 1:nGauss]
    elseif name=="NeuralNetwork2D"
        mat = [NeuralNetwork2D(props) for i = 1:nGauss]
    else
        error("Not implemented yet: $name")
    end
    strain = Array{Array{Float64}}(undef, length(weights))
    FiniteStrainContinuum(eledim, mat, elnodes, props, coords, dhdx, weights, hs, strain)
end

Every time we add a new material, we need to modify this structure. This is not very desirable. Instead, we can have a function

function get_element(s)
    if name=="PlaneStrain"
        PlaneStrain
    elseif name=="Scalar1D"
        Scalar1D
    ...
end

This can also be achieved via Julia macros, thanks to the powerful meta-programming feature in ADCME.

Summary

We have introduced some important design patterns that facilitate design maintainable and extendable software. Design patterns should not be viewed as rules to abide by, but they are useful principles in face of design difficulties. As mentioned, Julia provides powerful meta-programming features. These features can be used in the design patterns to simplify implementations. Meta-programming will be discussed in a future section.