Advantages of Julia vs Mojo

Mojo does not use the same language as Julia when it comes to composability (I.e. functions that are implemented by methods for specific argument types).
However, it seems to achieve the same behaviour by overloading functions declared with the fn keyword (see the docs)
So different function implementations are called based on the argument types.
There is also mechanism for casting arguments if the argument types are not an exact match to those in the function’s definition. I have not worked with Mojo, but I was left with the impression that the multiple dispatch is effectively there, even if it is not called out as such.

I don’t think so. Try to implement the following in Mojo:

julia> foo(::AbstractString) = println("A string!");

julia> foo(::Int) = println("An Int!");

julia> for val in ["Hello", 12]
       foo(val)
       end
A string!
An Int!

I tried on the Mojo playground, it was an interesting experience…

If you get that to work, try with functions of two arguments, overloading on both arguments. If that works, that would be similar to what you can do in Julia.

In any case I’d be interested to see solutions for the above from an experienced Mojo developer because I found it weirdly complicated to get anything like the above example work (and when it did compile it didn’t give the expected result).

6 Likes

Chris Lattner has explicitly said no to multiple dispatch, and I’m certain he understands the implications. Free functions (I believe) don’t support dispatch in the same way Julia does.

As with all dispatch conversations though there are always ways to express the same functionality in each language. The differences in how each language expresses overloading can be super subtle from both an ergonomics and performance perspective.

3 Likes

Many languages, such as C++, have function overloading. Simple example: Compiler Explorer
The difference between overloading and multiple dispatch is that multiple dispatch semantically happens at runtime, while overloading is required to happen at compile time.
Personally, I prefer overloading because I prefer to have the guarantee when needed, and wish fewer people went with designs relying on multiple dispatches for control flow (the pattern I prefer is packages like SymTypes or Expronicon + MLStyle’s @match, which is of course a style supported by statically typed ML languages, and those inspired by them such as Rust).

Multiple dispatch vs overloading is mostly about whether you prefer dynamic or static typing. Overloading makes more sense for Mojo’s static fn subset.

8 Likes

I wrote up a comparison of dispatch between Java and Julia based around the premise of the following video.

I do not have a strong desire to try Mojo under the current terms.

Java version

import java.util.Scanner;

class Hole {
    public String toString() {
        return this.getClass().getName();
    }
}
class SquareHole extends Hole { }
class CircleHole extends Hole { }
class TriangleHole extends Hole { }
class SemiCircleHole extends Hole { }
class RectangleHole extends Hole { }
class ArchHole extends Hole { }

class Block {
    public String toString() {
        return this.getClass().getName();
    }
    public Hole whichHole(TestDispatch test) {
        return test.whichHole(this);
    }
}
class Cube extends Block { }
class Cylinder extends Block { }
class TriangularPrism extends Block { }
class RectangularPrism extends Block { }
class SemiCirclePrism extends Block { }
class ArchPrism extends Block {
    public Hole whichHole(TestDispatch test) {
        return test.whichHole(this);
    }
}

class TestDispatch {
    public Hole whichHole(Block b) {
        return new SquareHole();
    }
    public Hole whichHole(Cube b) {
        return new SquareHole();
    }
    public Hole whichHole(Cylinder b) {
        return new CircleHole();
    }
    public Hole whichHole(TriangularPrism b) {
        return new TriangleHole();
    }
    public Hole whichHole(RectangularPrism b) {
        return new RectangleHole();
    }
    public Hole whichHole(SemiCirclePrism b) {
        return new SemiCircleHole();
    }
    public Hole whichHole(ArchPrism b) {
        return new ArchHole();
    }

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        TestDispatch test = new TestDispatch();
        Block[] blocks = { new Cube(), new Cylinder(), new TriangularPrism(), new RectangularPrism(), new SemiCirclePrism(), new ArchPrism() };
        String guess;

        for (Block b: blocks) {
            System.out.println("Which hole does the " + b + " go into?");
            guess = scanner.nextLine();
            System.out.println("You guessed the " + guess + ". The answer is " + b.whichHole(test));
            System.out.println();
        }
    }
}

Java Input and Output

$ java TestDispatch
Which hole does the Cube go into?
SquareHole
You guessed the SquareHole. The answer is SquareHole

Which hole does the Cylinder go into?
CircleHole
You guessed the CircleHole. The answer is SquareHole

Which hole does the TriangularPrism go into?
TriangleHole
You guessed the TriangleHole. The answer is SquareHole

Which hole does the RectangularPrism go into?
SquareHole
You guessed the SquareHole. The answer is SquareHole

Which hole does the SemiCirclePrism go into?
SemiCircleHole
You guessed the SemiCircleHole. The answer is SquareHole

Which hole does the ArchPrism go into?
ArchHole
You guessed the ArchHole. The answer is ArchHole

At each iteration of the main loop, b.whichHole(test) is called. This calls Block.whichHole(test) which then calls TestDispatch.whichHole(this). Here all Java knows at compile time is that this is a Block, so everything gets mapped to SquareHole. The exception is ArchPrism which has an explicitly overleaded whichHole method.

Julia version

module TestMultipleDispatch

export Hole, SquareHole, CircleHole, SemiCircleHole, ArchHole
export Block, Cube, Cylinder, SemiCircularPrism, RectangularPrism, ArchPrism

abstract type Hole end
struct SquareHole <: Hole end
struct RectangleHole <: Hole end
struct CircleHole <: Hole end
struct SemiCircleHole <: Hole end
struct ArchHole <: Hole end

abstract type Block end
struct Cube <: Block end
struct Cylinder <: Block end
struct SemiCircularPrism <: Block end
struct RectangularPrism <: Block end
struct ArchPrism <: Block end

struct TestDispatch
    whichHole::Function
    function TestDispatch()
        this = new(x->whichHole(this, x))
        return this
    end
end

const blocks = Block[Cube(), Cylinder(), SemiCircularPrism(), RectangularPrism(), ArchPrism()];

whichHole(::TestDispatch, ::Block) = SquareHole()
whichHole(::TestDispatch, ::Cylinder) = CircleHole()
whichHole(::TestDispatch, ::SemiCircularPrism) = SemiCircleHole()
whichHole(::TestDispatch, ::RectangularPrism) = RectangleHole()
whichHole(::TestDispatch, ::ArchPrism) = ArchHole()

function askWhichHole(test::TestDispatch, b::Block)
    println("Which hole does the $b go into?")
    guess = readline()
    println("You guessed $guess. The answer is " * string(test.whichHole(b)));
    println();
end

function main()
    test = TestDispatch()
    askWhichHole.((test,), blocks)
    return nothing
end


end # module TestMultipleDispatch

if @__FILE__() == abspath(PROGRAM_FILE)
    using .TestMultipleDispatch
    TestMultipleDispatch.main()
end

Julia Input and Output

$ julia test_dispatch.jl 
Which hole does the Cube() go into?
SquareHole
You guessed SquareHole. The answer is SquareHole()

Which hole does the Cylinder() go into?
CircleHole
You guessed CircleHole. The answer is CircleHole()

Which hole does the SemiCircularPrism() go into?
SemiCircleHole
You guessed SemiCircleHole. The answer is SemiCircleHole()

Which hole does the RectangularPrism() go into?
RectangularHole
You guessed RectangularHole. The answer is Main.TestMultipleDispatch.RectangleHole()

Which hole does the ArchPrism() go into?
ArchHole
You guessed ArchHole. The answer is ArchHole()
18 Likes

Julia’s multiple dispatch also encompasses abstract inheritance, automatically choosing the most specific method definition in the type hierarchy tree. Even forgetting about run-time dispatch, I wish C++'s static overloading is as powerful (or maybe it is, and I’m just ignorant of it). Julia’s multiple dispatch is a joy to use even when you require all the dispatch to be resolvable at compile time, i.e. de-virtualized.

C++20’s static overloading is powerful, especially concepts.
Concepts can be based on properties and capabilities of a struct, e.g. sizeof of whether certain methods are defined (and optionally, whether those methods return types meeting other concepts, such as same_as or convertible_to a particular type).
In the above C++ example, I define 3 concepts, and their conjunctions (&&).
I show all 2^3=8 combinations with single-arg dispatch, and pick two of the concepts for all (2^2)^2 = 16 combinations for two-arg dispatch.
The printing shows ambiguity resolution, automatically choosing the most specific definition…not with respect to a type tree hierarchy, but with respect to fairly arbitrary properties you can define, that do not need to form tree!

Trait systems (like C++'s concepts) are more powerful than type trees.
Not all concepts can fit neatly on the same tree, as there may be more than one property you care about. For example, whether an array is statically sized, like SArray and MArray but not Array, or whether it is mutable, like MArray and Array, but not SArray. Static sizing and mutability are somewhat arbitrary concepts, and you might rather have your code be in terms of these, than (e.g.) closed unions of known types meeting some property or the other (but I also think writing non-generic code often isn’t so bad).

For this particular example, ArrayInterface.jl exists to define a lot of traits on Arrays, so we can still do things manually. Taking the Fooable example from C++ above:

function Fooable(::Type{T}) where {T}
    F = Base.promote_op(foo, T)
    F === Union{} && return false
    PF = Base.promote_op(convert, Type{ProxyFoo}, F)
    return PF == ProxyFoo
end

function fooable_dispatch(x)
    fooable_dispatch(x, Val(fooable(typeof(x))))
end
function fooable_dispatch(x, ::Val{false})
    # generic fallback code that doesn't require fooaility
end
function fooable_dispatch(x, ::Val{true})
    # code taking advantage of/requiring fooability
end

vs C++, from my godbolt example for those not wishing to follow the link:

template <typename T>
concept Fooable = requires(T t){
  { foo(t) } -> std::convertible_to<ProxyFoo>;
};
void bar(auto){
    // generic fallback code that doesn't require fooability
}
void bar(Fooable auto){
    // code taking advantage of/requiring fooability
}

But this is much clunkier than C++, and promote_op’s docstring explicitly warns you not to use it, because it is brittle. So the Julia version might not be robust, but it should be fairly reliable in practice if your code is statically inferreable.

C++'s equivalent, decltype(...), is great – and also guaranteed not to actually run any of the code within the parenthesis, so you could get the return type of arbitrary computations, should you need it, without worrying about side effects.

5 Likes