Design patterns: good practices and structured thinking
Every software developer has a desire to write better code. A desire to improve system performance. A desire to design software that is easy to maintain, easy to understand and explain.
Design patterns are recommendations and good practices accumulating knowledge of experienced programmers.
The highest level of experience contains the design guiding principles:
- SOLID: Single Responsibility, Open/Closed, Liskov Substitution, Interface
- Segregation, Dependency Inversion
- DRY: Don't Repeat Yourself
- KISS: Keep It Simple, Stupid!
- POLA: Principle of Least Astonishment
- YAGNI: You Aren't Gonna Need It (overengineering)
- POLP: Principle of Least Privilege
While these high-level concepts are intuitive, they are too general to give specific answers.
More detailed patterns arise for programming paradigms (declarative, imperative) with specific instances of functional or object-oriented programming.
The concept of design patterns originates in the OOP paradigm. OOP defines a strict way how to write software. Sometimes it is not clear how to squeeze real world problems into those rules. Cookbook for many practical situations
- Gamma, E., Johnson, R., Helm, R., Johnson, R. E., & Vlissides, J. (1995). Design patterns: elements of reusable object-oriented software. Pearson Deutschland GmbH.
Defining 23 design patterns in three categories. Became extremely popular.
(C) Scott Wlaschin
Is julia OOP or FP? It is different from both, based on:
- types system (polymorphic)
- multiple dispatch (extending single dispatch of OOP)
- functions as first class
- decoupling of data and functions
- macros
Any guidelines to solve real-world problems?
- Hands-On Design Patterns and Best Practices with Julia Proven solutions to common problems in software design for Julia 1.x Tom Kwong, CFA
Fundamental tradeoff: rules vs. freedom
- freedom: in the C language it is possible to access assembler instructions, use pointer arithmetics:
- it is possible to write extremely efficient code
- it is easy to segfault, leak memory, etc.
- rules: in strict languages (strict OOP, strict functional programming) you lose freedom for certain guarantees:
- e.g. strict functional programming guarantees that the program provably terminates
- operations that are simple e.g. in pointer arithmetics may become clumsy and inefficient in those strict rules.
- the compiler can validate the rules and complain if the code does not comply with them.
Julia is again a dance between freedom and strict rules. It is more inclined to freedom. Provides few simple concepts that allow to construct design patterns common in other languages.
- the language does not enforce too many formalisms (via keywords (interface, trait, etc.) but they can be
- the compiler cannot check for correctness of these "patterns"
- the user has a lot of freedom (and responsibility)
- lots of features can be added by Julia packages (with various level of comfort)
- macros
Read:
Design Patterns of OOP from the Julia viewpoint
OOP is currently very popular concept (C++, Java, Python). It has strengths and weaknesses. The Julia authors tried to keep the strength and overcome weaknesses.
Key features of OOP:
- Encapsulation
- Inheritance
- Polymorphism
Classical OOP languages define classes that bind processing functions to the data. Virtual methods are defined only for the attached methods of the classes.
Refers to bundling of data with the methods that operate on that data, or the restricting of direct access to some of an object's components. Encapsulation is used to hide the values or state of a structured data object inside a class, preventing direct access to them by clients in a way that could expose hidden implementation details or violate state invariance maintained by the methods.
There are many discussions how to make Julia to behave like an OOP. The best implementation to our knowledge is ObjectOriented
Encapsulation Advantage: Consistency and Validity
With fields of data structure freely accessible, the information may become inconsistent.
mutable struct Grass <: Plant
id::Int
size::Int
max_size::Int
end
What if I create Grass with larger size than max_size
?
grass = Grass(1,50,5)
Freedom over Rules. Maybe I would prefer to introduce some rules.
Some encapsulation may be handy keeping it consistent. Julia has inner constructor
.
mutable struct Grass2 <: Plant
id::Int
size::Int
max_size::Int
Grass2(id,sz,msz) = sz > msz ? error("size can not be greater that max_size") : new(id,sz,msz)
end
When defined, Julia does not provide the default outer constructor.
But fields are still accessible:
grass.size = 10000
Recall that grass.size=1000
is a syntax of setproperty!(grass,:size,1000)
, which can be redefined:
function Base.setproperty!(obj::Grass, sym::Symbol, val)
if sym==:size
@assert val<=obj.max_size "size have to be lower than max_size!"
end
setfield!(obj,sym,val)
end
Function setfield!
can not be overloaded.
Julia has partial encapsulation via a mechanism for consistency checks.
The mutability applies to the structure and not to encapsulated structures.
struct Foo
x::Float64
y::Vector{Float64}
z::Dict{Int,Int}
end
In the structure Foo
, x
cannot be mutated, but fields of y
and key-value pairs of z
can be mutated, because they are mutable containers. But I cannot replace y
with a different Vector
.
Encapsulation Disadvantage: the Expression Problem
Encapsulation limits the operations I can do with an object. Sometimes too much. Consider a matrix of methods/types(data-structures)
Consider an existing matrix of data and functions:
data \ methods | find_food | eat! | grow! | ||
---|---|---|---|---|---|
Wolf | |||||
Sheep | |||||
Grass |
You have a good reason not to modify the original source (maintenance).
Imagine we want to extend the world to use new animals and new methods for all animals.
Object-oriented programming
- classes are primary objects (hierarchy)
- define animals as classes ( inheriting from abstract class)
- adding a new animal is easy
- adding a new method for all animals is hard (without modifying the original code)
Functional programming
- functions are primary
- define operations
find_food
,eat!
- adding a new operation is easy
- adding new data structure to existing operations is hard
Solutions:
- multiple-dispatch = julia
- open classes (monkey patching) = add methods to classes on the fly
- visitor pattern = partial fix for OOP [extended visitor pattern using dynamic_cast]
Morale:
- Julia does not enforces creation getters/setters by default (setproperty is mapped to setfield)
- it provides tools to enforce access restriction if the user wants it.
- can be used to imitate objects:
https://stackoverflow.com/questions/39133424/how-to-create-a-single-dispatch-object-oriented-class-in-julia-that-behaves-l/39150509#39150509
Polymorphism:
Polymorphism is the method in an object-oriented programming language that performs different things as per the object’s class, which calls it. With Polymorphism, a message is sent to multiple class objects, and every object responds appropriately according to the properties of the class.
Example animals of different classes make different sounds. In Python:
class Sheep:
def __init__(self, energy, Denergy):
self.energy = energy
self.Denergy = Denergy
def make_sound(self):
print("Baa")
sheep.make_sound()
wolf.make_sound()
Will make distinct sounds (baa, Howl).
Can we achieve this in Julia?
make_sound(s::Sheep)=println("Baa")
make_sound(w::Wolf)=println("Howl")
Virtual methods in OOP are typically implemented using Virtual Method Table, one for each class.
Julia has a single method table. Dispatch can be either static or dynamic (slow).
Freedom vs. Rules.
- Duck typing is a type of polymorphism without static types
- more programming freedom, less formal guarantees
- julia does not check if
make_sound
exists for all animals. May result inMethodError
. Responsibility of a programmer.- define
make_sound(A::AbstractAnimal)
- define
So far, the polymorphism coincides for OOP and julia because the method had only one argument => single argument dispatch.
Multiple dispatch is an extension of the classical first-argument-polymorphism of OOP, to all-argument polymorphism.
How to code polymorphic behavior of interaction between two agents, e.g. an agent eating another agent in OOP?
Complicated.... You need a "design pattern" for it.
class Sheep(Animal):
energy: float = 4.0
denergy: float = 0.2
reprprob: float = 0.5
foodprob: float = 0.9
# hard, if not impossible to add behaviour for a new type of food
def eat(self, a: Agent, w: World):
if isinstance(a, Grass)
self.energy += a.size * self.denergy
a.size = 0
else:
raise ValueError(f"Sheep cannot eat {type(a).__name__}.")
Consider an extension to:
- Flower : easy
- PoisonousGrass: harder
Simple in Julia:
eat!(w1::Sheep, a::Grass, w::World)=
eat!(w1::Sheep, a::Flower, w::World)=
eat!(w1::Sheep, a::PoisonousGrass, w::World)=
Boiler-plate code can be automated by macros / meta programming.
Inheritance
Is the mechanism of basing one object or class upon another object (prototype-based inheritance) or class (class-based inheritance), retaining similar implementation. Deriving new classes (sub classes) from existing ones such as super class or base class and then forming them into a hierarchy of classes. In most class-based object-oriented languages, an object created through inheritance, a "child object", acquires all the properties and behaviors of the "parent object" , with the exception of: constructors, destructor, overloaded operators.
Most commonly, the sub-class inherits methods and the data.
For example, in python we can design a sheep with additional field. Think of a situation that we want to refine the reproduction procedure for sheeps by considering differences for male and female. We do not have information about gender in the original implementation.
In OOP, we can use inheritance.
class Sheep:
def __init__(self, energy, Denergy):
self.energy = energy
self.Denergy = Denergy
def make_sound(self):
print("Baa")
class SheepWithGender(Sheep):
def __init__(self, energy, Denergy,gender):
super().__init__(energy, Denergy)
self.gender = gender
# make_sound is inherited
# Can you do this in Julia?!
Simple answer: NO, not exactly
- Sheep has fields, is a concrete type, we cannot extend it.
- with modification of the original code, we can define AbstractSheep with subtypes Sheep and SheepWithGender.
- But methods for AbstractAnimal works for sheeps! Is this inheritance?
Inheritance vs. Subtyping
Subtle difference:
- subtyping = equality of interface
- inheritance = reuse of implementation
In practice, subtyping reuse methods, not data fields.
We have seen this in Julia, using type hierarchy:
agent_step!(a::Animal, w::World)
- all animals subtype of
Animal
"inherit" this method.
The type hierarchy is only one way of subtyping. Julia allows many variations, e.g. concatenating different parts of hierarchies via the Union{}
type:
fancy_method(O::Union{Sheep,Grass})=println("Fancy")
Is this a good idea? It can be done completely Ad-hoc! Freedom over Rules.
There are very good use-cases:
- Missing values:
x::AbstractVector{<:Union{<:Number, Missing}}
With parametric types, unions and other construction, subtype resolution may become a complicated problem. Julia can even crash. (Jan Vitek's Keynote at JuliaCon 2021)[https://www.youtube.com/watch?v=LT4AP7CUMAw]
Sharing of data field via composition
Composition is also recommended in OOP: (Composition over ingeritance)[https://en.wikipedia.org/wiki/Compositionoverinheritance]
struct ⚥Sheep <: Animal
sheep::Sheep
sex::Symbol
end
If we want our new ⚥Sheep to behave like the original Sheep, we need to forward the corresponding methods.
eat!(a::⚥Sheep, b::Grass, w::World)=eat!(a.sheep, b, w)
and all other methods. Routine work. Boring! The whole process can be automated using macros @forward
from Lazy.jl.
Why so complicated? Wasn't the original inheritance tree structure better?
- multiple inheritance:
- you just compose two different "trees".
- common example with ArmoredVehicle = Vehicle + Weapon
- Do you think there is only one sensible inheritance tree?
Think of an inheritance tree of a full scope Animal world.
Idea #1: Split animals by biological taxonomy
Hold on.
- Sharks and dolphins can swim very well!
- Both bats and birds fly similarly!
Idea #2: Split by the way they move!
Idea #3: Split by way of ...
In fact, we do not have a tree, but more like a matrix/tensor:
swims | flies | walks | |
---|---|---|---|
birds | penguin | eagle | kiwi |
mammal | dolphin | bat | sheep,wolf |
insect | backswimmer | fly | beetle |
Single type hierarchy will not work. Other approaches:
- interfaces
- parametric types
Analyze what features of animals are common and compose the animal:
abstract type HeatType end
abstract type MovementType end
abstract type ChildCare end
mutable struct Animal{H<:HeatType,M<:MovementType,C<:ChildCare}
id::Int
...
end
Now, we can define methods dispatching on parameters of the main type.
Composition is simpler in such a general case. Composition over inheritance.
A simple example of parametric approach will be demonstarted in the lab.
Interfaces: inheritance/subtyping without a hierarchy tree
In OOP languages such as Java, interfaces have a dedicated keyword such that compiler can check correctness of the interface implementation.
In Julia, interfaces can be achieved by defining ordinary functions. Not so strict validation by the compiler as in other languages. Freedom...
Example: Iterators
Many fundamental objects can be iterated: Arrays, Tuples, Data collections...
- They do not have any common "predecessor". They are almost "primitive" types.
- they share just the property of being iterable
- we do not want to modify them in any way
Example: of interface Iterators
defined by "duck typing" via two functions.
Required methods | Brief description |
---|---|
iterate(iter) | Returns either a tuple of the first item and initial state or nothing if empty |
iterate(iter, state) | Returns either a tuple of the next item and next state or nothing if no items remain |
Defining these two methods for any object/collection C
will make the following work:
for o in C
# do something
end
- The compiler will not check if both functions exist.
- If one is missing, it will complain about it when it needs it
- The error message may be less informative than in the case of formal definition
Note:
- even iterators may have different features: they can be finite or infinite
- for finite iterators we can define useful functions (
collect
) - how to pass this information in an extensible way?
Poor solution: if statements.
function collect(iter)
if iter isa Tuple...
end
The compiler can do that for us.
Traits: cherry picking subtyping
Trait mechanism in Julia is build using the existing tools: Type System and Multiple Dispatch.
Traits have a few key parts:
- Trait types: the different traits a type can have.
- Trait function: what traits a type has.
- Trait dispatch: using the traits.
From iterators:
# trait types:
abstract type IteratorSize end
struct SizeUnknown <: IteratorSize end
struct HasLength <: IteratorSize end
struct IsInfinite <: IteratorSize end
# Trait function: Input is a Type, output is a Type
IteratorSize(::Type{<:Tuple}) = HasLength()
IteratorSize(::Type) = HasLength() # HasLength is the default
# ...
# Trait dispatch
BitArray(itr) = gen_bitarray(IteratorSize(itr), itr)
gen_bitarray(isz::IteratorSize, itr) = gen_bitarray_from_itr(itr)
gen_bitarray(::IsInfinite, itr) = throw(ArgumentError("infinite-size iterable used in BitArray constructor"))
What is needed to define for a new type that I want to iterate over?
Do you still miss inheritance in the OOP style?
Many packages automating this with more structure:
- https://github.com/andyferris/Traitor.jl
- https://github.com/mauro3/SimpleTraits.jl
- https://github.com/tk3369/BinaryTraits.jl
Functional tools: Partial evaluation
It is common to create a new function which "just" specify some parameters.
_prod(x) = reduce(*,x)
_sum(x) = reduce(+,x)
Functional tools: Closures
A technique for implementing lexically scoped name binding in a language with first-class functions. Operationally, a closure is a record storing a function together with an environment.
- originates in functional programming
- now widespread in many common languages, Python, Matlab, etc..
- memory management relies on garbage collector in general (can be optimized by compiler)
Example
function adder(x)
return y->x+y
end
creates a function that "closes" the argument x
. Try: f=adder(5); f(3)
.
x = 30;
function adder()
return y->x+y
end
creates a function that "closes" variable x
.
f = adder(10)
f(1)
g = adder()
g(1)
Such function can be passed as an argument: together with the closed data.
Implementation of closures in julia: documentation
Closure is a record storing a function together with an environment. The environment is a mapping associating each free variable of the function (variables that are used locally, but defined in an enclosing scope) with the value or reference to which the name was bound when the closure was created.
function adder(x)
return y->x+y
end
is lowered to (roughly):
struct ##1{T}
x::T
end
(_::##1)(y) = _.x + y
function adder(x)
return ##1(x)
end
Note that the structure ##1 is not directly accessible. Try f.x
and g.x
.
Functor = Function-like structure
Each structure can have a method that is invoked when called as a function.
(_::Sheep)()= println("🐑")
You can think of it as sheep.default_method()
.
Coding style
From Flux.jl
:
function train!(loss, ps, data, opt; cb = () -> ())
ps = Params(ps)
cb = runall(cb)
@progress for d in data
gs = gradient(ps) do
loss(batchmemaybe(d)...)
end
update!(opt, ps, gs)
cb()
end
end
Is this confusing? What can cb()
do and what it can not?
Note that function train!
does not have many local variables. The important ones are arguments, i.e. exist in the scope from which the function was invoked.
loss(x,y)=mse(model(x),y)
cb() = @info "training" loss(x,y)
train!(loss, ps, data, opt; cb=cb)
Usage
Usage of closures:
- callbacks: the function can also modify the enclosed variable.
- abstraction: partial evaluation
Inference of types may be difficult in closures: https://github.com/JuliaLang/julia/issues/15276