2025-01-12
There are two types of programming languages: Statically typed systems such as C++, where each variable must be of a particular type before execution, and dynamically typed systems, where the type is not known until runtime. Julia is a dynamically typed language, but still has the ability to specify certain types for better efficiency.
Types in Julia are organised in a hierarchy, which is very similar to inheritance in object-oriented languages such as C++, except that it also works for primitive types. Each type has exactly one parent type and possibly several child types, which can be determined using the supertype and subtype cmmands.
This way we can display the complete type tree:
Types in Julia.
This figure was created with draw.io and is hereby licensed under Public Domain (CC0 1.0)
As you can see, each type is a subtype of the type Any. We can check whether a type is a subtype of another using the <: operator.
Concrete types such as Float64 or Int64 can be instantiated, whereas abstract types exist only in the type hierarchy.
There are also composite types, which are made up of many smaller types.
Important
Composite types in Julia are not the same as classes in other languages. They don’t support inheritance and can’t have member functions.
To instantiate a variable of that type, we call it’s constructor.
As usual, we can access the member variables of a composite type using the . notation.
By default, composite types are immutable, meaning they cannot be changed. However, an immutable object can contain mutable fields, such as arrays, which remain mutable.
To define a mutable type, use the mutable keyword. If you want to ensure that a particular field remains constant in an otherwise mutable object, you can do this using the const keyword.
TODO: Ist das wichtig?
Abstract, primitive and composite types are all instances of the same concept, DataType, which is the type of these types.
What if you want to specify that a function accepts signed and unsigned integers, but not bool? You can use a union type.
The concept is similar in other programming languages.
A particularly useful case of a Union type is Union{T, Nothing}, which would be equivalent to std::optional in C++.
Types in Julia can take parameters, so type declarations introduce a whole family of types. This concept is known in other programming languages as generic programming.
In other words, Julia’s type parameters are invariant.
Let’s say we want to write a generic function that can take Point{Float64} as an argument. The following method won’t work:
Since Point{Float64} is not a subtype of Point{Real}, the function can’t take Point{Float64} as an argument.
Alternatively, one could also write
Arrays are data structures that allow random access to elements. Arrays often represent vectors and matrices. Julia has built-in support for arrays, with indexing and slicing syntax similar to Python or MATLAB. A (column)-vector in Julia can be constructed directly using square brackets and a comma (or semicolon) as separators.
There are built-in functions for constructing common matrices:
Note that ranges in Julia are closed, i.e. they include the endpoint.
Indexing and Slicing works very similar to MATLAB/Python:
Array Slicing in Julia
This image was created using draw.io and is hereby released into the public domain (CC0 1.0)
By default, indexing and slicing return copies of the array. For performance reasons, it may sometimes be better to just get a view:
As you can see, changing the second element of B causes the corresponding element of A to change as well.
To use logical indexing (masking) in Julia, you must first map a lambda function to each element to create a mask. Alternatively, you can use dot syntax to vectorise a particular function.
You can use hcat and to combine multiple arrays:
Array concatenation in Julia
This image was created with draw.io and is hereby release into the public domain (CC0 1.0).
Broadcasting also works in Julia, but you have to explicitly use the broadcast function, so the syntax is a bit more verbose. That way, broadcasting cannot happen by accident.
Constructors are functions that create new instances of composite types. When a user defines a new composite type, Julia creates the default constructors. However, in some cases constructors need additional functionality, for example to enforce constraints (called invariants) through argument checking or transformation.
Here’s a simple example illustrating the use of constructors in Julia:
struct Rectangle{T <: Real}
width::T
height::T
# Inner constructor
function Rectangle(width::Real, height::Real)
@assert width >= 0 "Width must be non-negative!"
@assert height >= 0 "Height must be non-negative!"
promoted_type = promote_type(typeof(width), typeof(height))
new{promoted_type}(width, height)
end
# Default Constructor
Rectangle() = Rectangle(1.0, 1.0)
endThat way, you can construct a Rectangle via
but calling it with negative arguments results in an error:
Sometimes it can be useful to define a struct with some default values. This can be achieved either by using default arguments in the constructor,
or by using the Base.@kwdef macro:
TODO: Explain functors, make comparison with C++
A function in Julia can consist of multiple methods. When a user calls a function, the method that is actually executed depends on the type and number of arguments. This is very similar to function overloading in C++.
We can call this function in the usual way:
If there is no method available for the given arguments, this will result in a MethodError:
Providing a method for every possible combination of types can quickly get out of hand; it is more useful to define general methods where the parameters are abstract:
This function will work for any numeric type; non-numeric types will throw an error as expected.
Care should be taken to ensure that there are no conflicting methods for the same function. If more than one method is applicable to a particular combination of arguments, the function call is ambiguous and an error will occur.
In order to understand Multiple Dispatch, we first need to talk about Polymorphism.
Polymorphism, meaning “many forms,” is a fundamental concept in object-oriented programming (OOP) that allows objects of different classes to be treated as objects of a common type. It enables you to design code that is more flexible, extensible, and reusable.
#include <vector>
#include <iostream>
#include <memory>
class Animal {
public:
virtual void sound() const {
std::cout << "FALLBACK\n" << std::endl;
}
virtual ~Animal() = default;
};
class Dog : public Animal {
public:
void sound() const override {
std::cout << "bark\n";
}
};
class Cat : public Animal {
public:
void sound() const override {
std::cout << "miau\n";
}
};
int main()
{
auto ein = std::make_unique<Dog>();
auto sphinx = std::make_unique<Cat>();
ein->sound();
sphinx->sound();
}using System;
public class Animal
{
public virtual void Sound()
{
Console.WriteLine("FALLBACK");
}
}
public class Dog : Animal
{
public override void Sound()
{
Console.WriteLine("bark");
}
}
public class Cat : Animal
{
public override void Sound()
{
Console.WriteLine("miau");
}
}
public class Program
{
public static void Main(string[] args)
{
Animal ein = new Dog();
Animal sphinx = new Cat();
ein.Sound();
sphinx.Sound();
}
}As Julia is not an object-oriented language, the only way to achieve something similar is as follows:
This isn’t very spectacular yet; it looks like normal operator overloading. Things get more interesting when the functions have more arguments.
Multi-dispatch is the ability to choose which version of a function to call based on the runtime type of all arguments passed to the function call.
abstract type Pet end
struct Dog <: Pet; name::String end
struct Cat <: Pet; name::String end
function encounter(a::Pet, b::Pet)
verb = meets(a, b)
println("$(a.name) meets $(b.name) and $verb")
end
meets(a::Dog, b::Dog) = "sniffs"
meets(a::Dog, b::Cat) = "chases"
meets(a::Cat, b::Dog) = "hisses"
meets(a::Cat, b::Cat) = "slinks"
fido = Dog("Fido")
rex = Dog("Rex")
whiskers = Cat("Whiskers")
spots = Cat("Spots")
encounter(fido, rex)
encounter(fido, whiskers)
encounter(whiskers, rex)
encounter(whiskers, spots)Fido meets Rex and sniffs
Fido meets Whiskers and chases
Whiskers meets Rex and hisses
Whiskers meets Spots and slinks
#include <iostream>
#include <string>
class Pet {
public:
std::string name{};
};
class Dog : public Pet{};
class Cat : public Pet{};
std::string meets(Dog a, Dog b) { return "sniffs"; }
std::string meets(Dog a, Cat b) { return "chases"; }
std::string meets(Cat a, Dog b) { return "hisses"; }
std::string meets(Cat a, Cat b) { return "slinks"; }
std::string meets(Pet a, Pet b) {
return "FALLBACK";
}
void encounter(Pet a, Pet b) {
auto verb = meets(a, b);
std::cout << a.name << " meets "
<< b.name << " and " << verb << std::endl;
}
int main() {
Dog fido{"Fido"};
Dog rex{"Rex"};
Cat whiskers{"whiskers"};
Cat spots{"spots"};
encounter(fido, rex);
encounter(fido, whiskers);
encounter(whiskers, rex);
encounter(whiskers, spots);
}Fido meets Rex and FALLBACK
Fido meets whiskers and FALLBACK
whiskers meets Rex and FALLBACK
whiskers meets spots and FALLBACK
Doing something like this in C++ is not possible.
See also:
Functional programming is a programming paradigm where programs are constructed by applying and composing functions. It’s a declarative style of programming, meaning you describe what you want to achieve rather than how to achieve it (which is more typical of imperative programming)
The core concepts and characteristics of functional programming are as follows:
map: Transforms each element in a collection by applying a function to it, returning a new collection of the same size.filter: Creates a new collection containing only elements that satisfy a given predicate function.reduce: Reduces a collection to a single value by iteratively applying a function that combines elements.\[ \mathcal{N}(x | \mu, \sigma) = \frac{1}{\sqrt{2\pi}\sigma} \exp\left({- \frac{1}{2} \biggl( \frac{x - \mu}{\sigma} \biggr)^2 }\right) \] \[ \mathcal{N}(x | \mu, \sigma) = \frac{1}{\sqrt{2\pi}\sigma} \, e^{- \frac{1}{2} \left( \frac{x - \mu}{\sigma} \right)^2 } \]
Closure:
#include <cmath>
#include <functional>
#include <iostream>
#include <numbers>
std::function<double(double)> normal_distribution(double mu=0.0, double sigma=1.0) {
return [=](double x) {
return 1 / (std::sqrt(2 * std::numbers::pi) * sigma) * std::exp( -1/2 * std::pow( (x - mu) / sigma, 2));
};
}
int main()
{
auto f = normal_distribution(5.0, 2.0);
std::cout << f(7.5) << std::endl;
}Let’s say we want to solve LeetCode problem 557 (Reverse Words in a String III). Given a string str, reverse the characters in each word while still preserving the initial word order.
Example:
str = "The quick brown fox jumps over the lazy dog"
--> "ehT kciuq nworb xof spmuj revo eht yzal god"
This problem can be solved in a very elegant way using functional programming:
split the string at the space (’ ’) charactermap the reverse function to each elementjoin everything back together