How do you feel about Odin's alternative to methods?

This comes from a post on r/odinlang. I see there’s many different opinions and speculation flying around in the comments.

https://www.reddit.com/r/odinlang/comments/1mvnj2a/how_do_you_feel_about_odins_alternative_to_methods/

A decent explanation is found in the FAQ, for those that don’t know:

Odin also has the concept of import names for packages: this means procedures are declared within different scopes, meaning it would not make any sense syntactically.

One of Odin’s goals is simplicity and striving to only offer one way to do things, to improve clarity. x.f(y) meaning f(x, y) is ambiguous as x may have a field called f. It is not at all clear what this means.

It seems the Reddit commentors pine for the namespaces and organisational benefits of dot notation around functions. I’m not personally too invested in OOP, but just stepping back and thinking loosely and creatively for a moment, I wonder if virtual packages could define scopes…

#package foo{
    add :: proc(bing: int, bong: int) -> int
}

main :: proc() {
    foo.add(1, 2)
}

…where the above is essentially two packages in one file. I guess as long as these ‘sub-packages’ were isolated within a file it could, internally, work as an imported package, like a package folder in the directory but which is lexically in the file which imports it - I’m not pushing it, I just like theory-crafting.

One comment I found interesting, both because it seems to technically provide a ‘method’ (ha) for people wanting this, and because I:
a) Didn’t know -> was a symbol in Odin
b) Don’t understand how foo is called with just one argument

is the following snippet, which frankly I don’t understand, but it compiles and prints 20:

package main

import "core:fmt"

MyStruct :: struct {
    bar: int,
    method: proc(this: ^MyStruct, bla: int) -> int,
}

foo :: proc(this: ^MyStruct, bla: int) -> int {
    return bla * this.bar
}

main :: proc() {
    my_struct: MyStruct

    my_struct.bar = 10
    my_struct.method = foo

    fmt.println(my_struct->method(2))
}

Anyways please share your thoughts.

Believe it or not, it does exist in the overview:

-> operator (selector call expressions) #

The -> operator is called the selector call expression operator and is extremely useful for call procedures stored in vtables. Component Objective Model (COM) APIs is a great example of where this kind of thing is extremely useful (such as the Direct3D11 package).

x->y(123)
// is equivalent to
x.y(x, 123)

As the -> operator is effectively syntactic sugar, all of the same semantics still apply, meaning subtyping through using will still work as expected to allow for the emulation of type hierarchies.

I don’t really understand how the virtual packages thing you suggest would substitute for methods. Is it for organization? It would put stuff in it’s own namespace which you can access with the sub-package name, maybe could be useful if you have many data structures in a single package (like core:mem for example).

My workaround for methods is to cache some state as global data and use it implicitly:

g: ^Graph

@(deferred_none = with_end)
with :: proc(graph: ^Graph) -> bool {
	g = graph
	return graph != nil
}

@(private)
with_end :: proc() {
	g = nil
}

and then use it like this:

Node :: struct {
	pos: [2]f32,
}

_make_node :: proc(node: Node) {
	// access data implicity using g.
}

_make_directed_edge :: proc(nodes: [2]u32) {
	// access data implicity using g.
}

main :: proc() {
	graph: Graph
	if with(&graph) {
		for i in 0 ..< 4 {
			node := Node {
				pos = {f32(i), f32((i + 2) & 3)},
			}
			_make_node(node)
		}
		_make_directed_edge({2, 4})
	}
}

The good thing about this is that if you always make sure to use the ‘methods’ inside of with() block then you don’t have to do nil checks everywhere, as with() would return false and none of the code inside the block runs. The drawback being that you could only use one ‘instance’ at a time.

1 Like

This would be a lot more practical if you could put a constant in a struct:

MyStruct::struct{
    a:f32,
    b:f32,
    MyMethod::SomeFunction,
}

Filling your structs with actual function pointers is just a waste of resources.

I think our approaches span the spectrum - mine is almost purely visual/organisational (and also not possible), while yours seems to be a method in the most technical sense. Pretty cool; admittedly the motivation for me were the many comments in the Reddit thread of people saying all they really desired was the more superficial aspects of methods.

1 Like

It may not be possible to have a constant in a struct, but it is possible to have a constant struct definition of proc types pre-defined. This allows for organizing procedures of the same name, but function differently depending on the struct type they are used for. Not sure how practical this is or if this is on topic, but below is the closest I could come up with to organize procedures by some “object” name. I’m new to Odin, so sorry if this is a digression of the topic.

This keeps inline with Odin’s goal to keep methods and data seperate, but also allows for categorical naming conventions for procedures using a dot instead of underscore. i.e.: category.procedure() instead of category_procedure()

package Shapes
import "core:fmt"
import "core:math"

Circle_Method_Def :: #type proc(c: Circle) -> f32
Circle_Area : Circle_Method_Def : proc(c: Circle) -> f32 { return math.PI * (c.radius * c.radius) }
Circle_Circumference : Circle_Method_Def : proc(c: Circle) -> f32 { return 2 * math.PI * c.radius }
Circle_Methods :: struct { area: Circle_Method_Def, circumference: Circle_Method_Def }

Square_Method_Def :: #type proc(s: Square) -> f32
Square_Area : Square_Method_Def : proc(s: Square) -> f32 { return s.side * s.side }
Square_Perimeter : Square_Method_Def : proc(s: Square) -> f32 { return s.side * 4 }
Square_Methods :: struct { area: Square_Method_Def, perimeter: Square_Method_Def }

circle : Circle_Methods : { Circle_Area, Circle_Circumference }
square : Square_Methods : { Square_Area, Square_Perimeter }

Circle :: struct { radius: f32 }
Square :: struct { side: f32 }

main :: proc() {
  mycircle := Circle{10}
  fmt.printfln("area: %v", circle.area(mycircle))
  fmt.printfln("circumference: %v", circle.circumference(mycircle))

  mysquare := Square{20}
  fmt.printfln("area: %v", square.area(mysquare))
  fmt.printfln("perimeter: %v", square.perimeter(mysquare))
}
2 Likes

You’d probably rather just do:

area::proc{Circle_Area,Square_Area}

And then you can call Shapes.area on either. But people would like to get rid of the Shapes. part since that could be implicit in the struct passed to the function.

2 Likes

It makes sense that a procedural language has procedures and not methods. Methods are not zero-cost, either. So the language isn’t obliged to have them as it’s not part of the intended design.

So read(obj) to me is syntactically not far off obj.read() enough for it to be an issue.

I think where people take umbrage is with the philosophy in the FAQ that “data should not have behavior”, but yet we find ourselves having to weakly namespace our procedures like obj_read(obj: ^obj) anyway. I think this problem goes away easily if you:

a) Accept that it’s a procedural language.

b) Also accept that data actually does have behavior, and then you don’t feel so conflicted with weak namespacing like obj_read(obj: ^obj).

c) You don’t care about weak namespacing because the procedure signature tells you what object it acts on anyway (at perhaps the cost of casual readability, something that can be fixed by good naming).

What you don’t really want to do is make your life more complicated by trying to carve a method syntax out of a language that doesn’t support it directly.

4 Likes

Truth. My above example, for me, was just a fun exercise to see what is possible. The main reason(s) I’m heavily drawn to Odin, is the data and procedure driven focus of the syntax. In c/c++ it was a headache to manage objects with inheritance, virtual functions, overloading, templates, etc thrown in. Since Odin does away with the object design part, I can just do the below and focus on functionality and data processing. Not to mention the syntactic sugar that makes overloading and parametric typing much more fun to work with in comparison. Package management is the icing on the :cake: for me.

area_circle :: proc(c: Circle) -> f32 { return math.PI * (c.radius * c.radius) }
area_square :: proc(s: Square) -> f32 { return s.side * s.side }
area :: proc{area_circle, area_square}

Much simpler to manage code wise. Also to my suprise, I’ve found it’s even easier to conceptualize after removing the OOP-ness.

2 Likes

Really, what is the cost of methods in Odin?

1 Like

Maybe the virtual methods when you can’t know the type at compile-time.
So you have a pointer to a proc for most method calls.
But I’m not sure if a proc_group doesn’t have a similar runtime-cost though.

You can’t do devirtualization, the step where the compiler can figure out if a virtual method is indeed a method call on type T and not a call on the interface/abstract class and remove the procedure pointer and replace it with a direct call.

A simple example is if you have interface Foo which is implemented both by Bar and Baz then if you have an array of ^Foo and the array is a mix of the two implementations it will be a dynamic dispatch. But if you do foo := cast(^Foo)&bar and call a method it could opt for static dispatch.

In Odin this isn’t a thing, because the compiler doesn’t have knowledge on what “implements” a interface. After all there’s no language level construct for an interface either. This means you’ll always have dynamic dispatch and the compiler can never optimise it away.

Now, that’s not always a problem, actually it’s very predictable performance wise.

Afaik, proc groups don’t have a runtime cost, it’s purely a compile time decision.

1 Like

(EDIT: Post was with wrong parent/replied-to post)

Hey, sorry, I meant to respond to @greybird 's question: “Really, what is the cost of methods in Odin?”.
The cost being that the virtual method has to remain a procedure pointer at runtime.

But devirtualisation is a good word that I’ll now start using :smiley:

Other than that, thanks for giving proper explanations there.
And I’m happy to know that proc groups do indeed use compile time types there.

2 Likes