Method of struct

I’ve being using Julia for less than a year, and I appreciate how multi-dispatch makes codes efficient. Meanwhile, I also miss the methods that come with Python or C++ classes. I know that a method obj.func(...) in Python is really func(obj, ...), and I understand the argument of not treating the first argument as something special. However, the first argument can be special sometimes. For example, the first argument of map() is hidden in the following example:

map(1:10) do x 
    2x 
end

In other situations, e.g. dict.haskey(key) is slightly easier to write and read than haskey(dict, key). Object methods also allow the construction of long but readable expressions like those in JavaScript.

I’m sure there’re many implementation details I’m ignorant of. But I just can’t help wondering if it’s possible to have Julia compile obj.func(...) to func(obj, ...) automatically if the latter definition exists?

TLDR no.

4 Likes

You can read this long, and still active, thread on the subject:

But the short answer is that the exact thing you suggest will not happen. Perhaps some improved piping syntax is possible.

6 Likes

:rofl: I thougt Oscar was providing a very comprehensive answer, so I decided to keep quiet…

Since he didn´t:

There are actually implementations of OO-style programming in Julia: GitHub - Suzhou-Tongyuan/ObjectOriented.jl: Conventional object-oriented programming in Julia without breaking Julia's core design ideas

And there are some specific situations in which it is convenient to make the objects contain the functions, such that the syntax object.f(...) works for that function.

However, probably the simplest answer is that if you stick to Julia you will sooner or later get used to it. Sometimes the OO style seems nicer, sometimes is the contrary. What one misses from the OO style is the autocompletion, but not the clarity of the syntax itself.

2 Likes

As others suggested, new users typically get used to the functional style of Julia pretty quickly. In some cases, e.g. when experimenting in REPL, I also use |> operator to chain the calls, e.g.:

x = 2.0
x |> sin |> cos |> tanh

It’s convenient because you don’t need to move the cursor to the beginning of the line and count opening and closing parentheses. Yet, for multiarg functions it’s pretty verbose:

x |> sin |> cos |> x -> x + 1

This syntax is also less readable, so I rarely use it in a package code, preferring normal call notation:

x = sin(x)
x = cos(x)
x = x + 1

So in 99% of cases it makes code better and more idiomatic. But in some rare cases you really, really want the OO-style dot syntax for function chaining. I faced such a case during work on Spark.jl, which mimics PySpark API. PySpark uses function chaining a lot! For example, creating a Spark session looks like this:

spark = SparkSession.builder.appName("Main").master("local").getOrCreate()

Splitting it into 4 lines of code (+1 line for every custom config) would look cumbersome! Also, since we mimic a Python library, we want an easy way to translate thousands of Python examples into Julia.

To support this (again, pretty specific) case, I wrote a simple macro @chainable which overloads struct’s getproperty() method in such a way that calling obj.f(args...) is translated into f(obj, args...). For example:

julia> struct Point x; y end

julia> plus_x(p1::Point, p2::Point) = Point(p1.x + p2.x, p1.y)
plus_x (generic function with 1 method)

julia> mul_y(p::Point, val::Real) = Point(p.x, p.y * val)
mul_y (generic function with 1 method)

julia> p1 = Point(1.0, 2.0)
Point(1.0, 2.0)

julia> p2 = Point(3.0, 4.0)
Point(3.0, 4.0)

julia> p1.plus_x(p2).mul_y(5)
Point(4.0, 10.0)

`@chainable` implementation
"""
    DotChainer{O, Fn}

See `@chainable` for details.
"""
struct DotChainer{O, Fn}
    obj::O
    fn::Fn
end

# DotChainer(obj, fn) = DotChainer{typeof(obj), typeof(fn)}(obj, fn)

(c::DotChainer)(args...) = c.fn(c.obj, args...)


"""
    @chainable T

Adds dot chaining syntax to the type, i.e. automatically translate:

    foo.bar(a)

into

    bar(foo, a)

For single-argument functions also support implicit calls, e.g:

    foo.bar.baz(a, b)

is treated the same as:

    foo.bar().baz(a, b)

Note that `@chainable` works by overloading `Base.getproperty()`,
making it impossible to customize it for `T`. To have more control,
one may use the underlying wrapper type - `DotCaller`.
"""
macro chainable(T)
    return quote
        function Base.getproperty(obj::$(esc(T)), prop::Symbol)
            if hasfield(typeof(obj), prop)
                return getfield(obj, prop)
            elseif isdefined(@__MODULE__, prop)
                fn = getfield(@__MODULE__, prop)
                return DotChainer(obj, fn)
            else
                error("type $(typeof(obj)) has no field $prop")
            end
        end
    end
end


function Base.getproperty(dc::DotChainer, prop::Symbol)
    if hasfield(typeof(dc), prop)
        return getfield(dc, prop)
    else
        # implicitely call function without arguments
        # and propagate getproperty to the returned object
        return getproperty(dc(), prop)
    end
end
2 Likes

Thank you @DNF @lmiq @dfdx for the explanations and helpful suggestions! I’m glad that it’s not me being stupid or ignorant, but there’re serious discussions and even implementations of similar ideas.

Again, I can understand why Julia developers may not want to implement this idea. But many people like (syntax) sugar for a reason, and the same question will keep popping up as Julia attract more people :grinning:

2 Likes

Please note that although object-oriented syntax may be convenient in some specific cases, the default functional style is still better suited for most situations. A notable example is high-order functions that usually take another function as its first argument:

broadcast(sin, x)
# or simply
sin.(x)

or functions with no single “main” object:

zip(xs, ys)

or symbolic operators:

1 - x

etc.

In general, functional syntax is much more flexible then the dot notation, and it’s worth to get used to it before searching for alternatives.

1 Like

Your point is well taken. But at the same time, it’s a reality that the first argument is special in many methods, and I hope Julia will eventually do something about it.

As far as I’m concerned, I don’t plan to use any third-party implemented syntax sugar because I came to Julia for performance, or I’d use Python.

1 Like

It’s worth at least checking out some of the piping packages that are in the ecosystem. I would recommend one of the following.

It’s funny how the dot seems to be hardwired/required for that syntax. With function chaining via |> and some overloads of partially applied ones, almost the same effect can be achieved:

plus_x(p2) = p1 -> plus_x(p1, p2)
mul_y(val) = p -> mul_y(p, val)

and then assuming the above definitions together with the original example

julia> p1 |> plus_x(p2) |> mul_y(5)
Point(4.0, 10.0)

Fun fact: In Haskell the dot denotes function composition and together with its automatic currying, we get a rather similar looking syntax

ghci> data Point = Point { x::Int, y::Int } deriving Show
ghci> plus_x p2 p1 = Point (x p1 + x p2) (y p1)
ghci> mul_y val p = Point (x p) (val * y p)
ghci> let p1 = Point 1 2; p2 = Point 3 4 in mul_y 5 . plus_x p2 $ p1
Point {x = 4, y = 10}

which reads from right-to-left though.

It’s enlightening to see how OOP chaining can be implemented in Julia in many ways. However, this is not my point. The fact that so many people are glued on to the dot syntax shows how natural this syntax is. Indeed, there’re a large class of functions that just seem naturally belong to their first argument. For example, dict.haskey(key) just reads and writes better than haskey(dict, key) for most people because Julia is a language that mostly reads from left to right. Accidentally, the dot syntax also provides a natural way to chain such methods together. This feature, of course, can be abused. But one can only teach not force people to code nicely.

1 Like

I don’t think that’s a good example. I don’t find
the OO better and don’t think that haskey belongs to the dictionary.

There are other examples in which I do feel that the function belongs to the object, like

person.eat("apple")

because the function is an action of the object and modifies it somehow.

In the dict example, the functional form extends naturally to asking if two dicts have the same key (haskey.(dict_collection, key)). Asking two people to eat the same apple is less natural.

Thus, I do think sometimes one style is more natural than the other. But people being used to one of those makes a great deal for what feels natural to begin with.

2 Likes

Correct me if I’m wrong, but I don’t think this works in Python either.

obj.foo(x) isn’t syntactic sugar for foo(obj, x) but for something like class_of(obj).foo(obj, x). If you try obj.bar(x) for an ‘ordinary’ function bar, it won’t work. You could ask yourself ‘why not’, but I think it’s that it would not make sense. obj.foo is meant for accessing member fields, properties, functions of obj, not for the kind of transformation you are thinking of.

Julia objects can also keep functions in their fields, but mostly functions are of the other type, and, just like in Python, require the bar(obj, x) call syntax.

obj.foo is for accessing something that belongs to an object, I think the proposed syntax is incompatible with that view.

1 Like

“Extended piping” syntax implemented in packages doesn’t bring any performance regressions - it amounts to executing the same code as you’d manually write. For example:

julia> using DataPipes

julia> @macroexpand @p [1, 2, 3] |> filter(_ > 1) |> __ .+ 1
# here I just manually removed comments and added readable variable names
let
    A = [1, 2, 3]
    B = filter(((a,)->begin
                    a > 1
                end), A)
    B .+ 1
end
1 Like

“Extended piping” syntax implemented in packages doesn’t bring any performance regressions - it amounts to executing the same code as you’d manually write.

What you said may be true. But I’m really not here for data piping or method chaining which is an accidental feature of the dot syntax :slightly_smiling_face:

Correct me if I’m wrong, but I don’t think this works in Python either.

I can’t claim to be a Python expert. But as you said, obj.foo(x) can be seen as a syntax sugar for cls.foo(obj, x), where foo() is a function defined in the class name space cls of obj.

Obviously, I’m not a Julia expert either. But I like Julia’s approach of not defining foo() in the class / struct name space so that foo() can be overloaded / multi-dispatched for different types. Maybe this is an obstacle to the dot syntax? I don’t know. All I’m saying is that the syntax obj.foo(args…) seems natural for a large class of functions.

In the dict example, the functional form extends naturally to asking if two dicts have the same key (haskey.(dict_collection, key) ).

How about dict_collecgtion..haskey(key) as the sugar syntax for haskey.(dict_collection, key) :upside_down_face:

Thus, I do think sometimes one style is more natural than the other. But people being used to one of those makes a great deal for what feels natural to begin with.

I agree. Maybe one would have to write mul(x, y) in Julia had we not all learned x * y in elementary school :grinning:

Sure we would. And it would be more natural than a.mul(b) .

Sure we would. And it would be more natural than a.mul(b) .

In this case, a.mul(b) is less natural than mul(a, b) because a isn’t more special to mul() than b (although it could be useful in chaining / piping). But once we’ve learned a * b, most people would prefer it over mul(a, b). Similarly, many people are glued on to the dot syntax once they’ve learned it because many functions do treat the first argument differently.

The situation is julia is much more consistent than eg in python. In python, some functions are methods and called with dots, some are freestanding functions such as len/map/… or a lot of numpy.xxx/scipy.xxx functions - without much (if any) intuition for any individual case.
Just try using instruments that julia gives you for some time, and probably you won’t miss the dot notation anymore. Note that autocomplete is independent to the dot vs piping syntax and is a matter of tooling - some improvements are happening there right now.