# On Julia's Programming Paradigm
**By Luo Xiuzhe**
It is recommended to have a basic understanding of Julia (as well as C++ and Python) before reading this article. Most articles in this column are not aimed at beginners.
Julia does not have `class` or inheritance — so how should one design programs? In recent months, I've been asked this question many times. I've also reviewed some code where people, unsure of how to structure things properly, ended up writing procedural-style code.
First, it's important to clarify that Julia hasn't abandoned the concept of *objects*. The absence of a `class` construct doesn’t mean there are no objects in Julia. From simple values like integers and floats to complex type instances, type definitions themselves, and even Julia’s own code expressions — all are objects. Put simply, an object in Julia is an instance of a certain type.
However, unlike languages with `class`, the way we think about code structure in Julia is different. In Julia, we design code around methods — sometimes referred to as **method-oriented programming**, rather than **object-oriented programming**.
Let me use an operator as an example. Suppose we have a class of operators: each has a matrix representation and can apply itself to a vector. The application method is consistent — reshape the vector into a matrix and perform matrix multiplication.
In Python, which uses the class model, we might write:
```python
class Operator(object):
def __init__(self):
pass
def apply(self, vector):
U = self.mat()
v = vector.reshape((len(vector) // U.shape[0], U.shape[0]))
return np.matmul(v, U)
def mat(self):
raise NotImplementedError
class OpA(Operator):
def mat(self):
return np.random.rand(2, 2)
class OpB(Operator):
def mat(self):
return np.eye(4)
```
We first define a matrix object and then think about what methods it can have — what actions it can perform. For example, every operator supports the `apply` method and has a corresponding matrix. We define a general `apply` method in the base class `Operator`, and let subclasses implement their specific matrices.
In Julia, however, we care more about **what can be done**, rather than **who can do it**. So instead of starting with an object, we start by defining a function — a generic behavior.
```julia
function apply end
```
We know that for a certain category of operators, there is a common implementation for `apply`. So we define an abstract type:
```julia
abstract type AbstractOperator end
function apply(op::AbstractOperator, v::Vector)
U = mat(op)
v = reshape(v, :, size(U, 1))
return v * U
end
```
Then we implement `mat` for different types of operators:
```julia
struct OpA end
struct OpB end
mat(::OpA) = rand(2, 2)
mat(::OpB) = eye(4)
```
At this point, you might think that just treating the first argument like `self` in Python makes them equivalent. But actually, it's not quite the same. Let's consider a more complex scenario.
Suppose we want to implement **ternary broadcasting** — an operation that behaves differently depending on the matrix types involved:
- For dense matrices, we iterate over all elements.
- For diagonal matrices, we only need to iterate over the diagonal entries.
- For combinations like sparse and diagonal matrices, we again iterate over diagonals but store results in a sparse matrix.
In C++, we'd typically use templates and partial specialization to handle this kind of dispatch:
```cpp
class MatrixBase { /* ... */ };
class Matrix : public MatrixBase { /* ... */ };
class Hermitian : public Matrix { /* ... */ };
class Diagonal : public MatrixBase { /* ... */ };
struct LinearStyle {}; // Iterate over all elements
struct DiagonalStyle {}; // Iterate only over diagonal entries
template <typename StyleA, typename StyleB, typename StyleC>
struct BroadcastStyle;
template <typename Style>
struct Broadcast {
static void apply(MatrixBase& A, const MatrixBase& B, const MatrixBase& C);
};
```
This allows compile-time dispatch based on matrix types, but it requires careful template design and partial specialization.
So how would we do this in Julia?
First, we define a generic `broadcast!` function (the `!` indicates in-place modification):
```julia
function broadcast! end
```
We define `broadcast!` based on input types. Since its behavior depends on the types of inputs, we first define a general version:
```julia
abstract type AbstractMatrix end
function broadcast!(A::AbstractMatrix, B::AbstractMatrix, C::AbstractMatrix)
broadcast!(broadcast_style(A, B, C), A, B, C)
end
```
To distinguish between styles, we define some types and rules:
```julia
struct Unknown end
struct LinearStyle end
struct DiagonalStyle end
style(A::AbstractMatrix) = Unknown()
style(A::Matrix) = LinearStyle()
style(A::Diagonal) = DiagonalStyle()
broadcast_style(args::AbstractMatrix...) = broadcast_style(style.(args)...)
broadcast_style(A::Type{<:Matrix}, B::Type{<:Matrix}, C::Type{<:Matrix}) = LinearStyle()
broadcast_style(A::Type{<:Diagonal}, B::Type{<:Diagonal}, C::Type{<:Diagonal}) = DiagonalStyle()
broadcast_style(A::Type{<:Diagonal}, B::Type{<:Diagonal}, C) = error("...")
```
Then we define concrete matrix types and reuse data structures:
```julia
struct TensorStorage
data::Vector{Float64}
offset::Int64
end
struct TensorShape
dims::Vector{Int64}
strides::Vector{Int64}
end
struct Matrix <: AbstractMatrix
storage::TensorStorage
shape::TensorShape
end
struct Hermitian <: AbstractMatrix
parent::Matrix
end
getindex(A::Hermitian, args...) = getindex(A.parent, args...)
struct Diagonal <: AbstractMatrix
parent::TensorStorage
shape::TensorShape
end
```
Finally, we implement the actual broadcasting logic:
```julia
function broadcast!(::LinearStyle, A, B, C)
# Normal element-wise iteration
end
# Other style implementations...
```
This simplified version resembles how broadcasting is implemented in Julia's standard library. The real implementation is more compact and handles arbitrary numbers of arguments and extensibility for new types.
---
### What Are the Advantages of This Approach in Julia?
1. **Powerful Type-Based Dispatch**:
Julia functions can pattern-match on types (not just values), allowing sophisticated compile-time logic via generated functions. This enables highly flexible dispatch strategies without changing interfaces, something hard to achieve in C++ with limited template partial specialization.
2. **Flexible Method Extension**:
Functions aren't tied to a single type. You can extend any function from anywhere, without needing to inherit from a base class. This avoids issues like diamond inheritance and multiple inheritance.
3. **Shorter Code Blocks Reduce Coupling**:
Each function stands on its own, making it easier to copy-paste and modify. This works great in environments like Jupyter notebooks, where you might write a small algorithm or formula inline.
4. **Natural Duck Typing**:
Because functions are defined independently of types, similar behaviors can be shared across unrelated types. For example, multiple linear maps can support `expmv` without sharing a common base class.
5. **Mathematical Expressiveness**:
Julia's design reflects years of discussion among mathematicians and scientists. Its syntax and paradigm make expressing mathematical constructs natural and intuitive — somewhat akin to Wolfram Mathematica.
---
### Potential Drawbacks
1. **No Data Inheritance**:
Without class-based inheritance, common data structures must be manually abstracted and forwarded, e.g., using `TensorStorage` and `TensorShape`. This could be improved in future versions with better support for interfaces.
2. **Fallback Behavior in Duck Typing**:
When implementing subtypes of abstract types like `AbstractArray`, you may inadvertently inherit many default method behaviors. Sometimes these work fine, but other times you may want them to throw errors. This leads to having to override many methods or restructure the type hierarchy.
---
### Conclusion
Yes, you can write purely functional code in Julia, but most real-world Julia code follows the method-oriented style outlined above. Understanding when and how to use this paradigm — and how it differs from traditional OOP — is key to effective Julia programming.
Different paradigms have different strengths and weaknesses. Julia is not object-oriented in the traditional sense, but it still retains the concept of objects — everything in Julia is an object. The absence of `class` does not imply the absence of object-like behavior.
I hope this article helps clarify some of the confusion for those interested in Julia.