Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions docs/types/dynamic-dispatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ implementation to invoke.

<!-- MarkdownTOC levels="2,3" autolink="true" -->

- [Static versus instance method calls](#static-versus-instance-method-calls)
- [Specificity](#specificity)
- [Multiple Dispatch](#multiple-dispatch)
- [Resolving Clashes on `Any`](#resolving-clashes-on-any)
Expand All @@ -27,6 +28,25 @@ implementation to invoke.

Another page related to [dispatch](../semantics/dispatch.md) exists.

## Static versus instance method calls

Unlike other programming languages like Java, Enso does not differentiate
between _static_ and _instance_ method calls. An instance method call is simply
a static method call with the `self` argument provided implicitly.

For example, with:

```
type T
Cons data
method self = self.data

obj = T.Cons 42
```

the method call `obj.method` is equivalent to `T.method obj`, which is
equivalent to `T.method self=obj`.
Copy link
Member

@JaroslavTulach JaroslavTulach Sep 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two things to consider when invoking a method in Enso:

  1. lookup of the method
  2. passing of self (and maybe Self) argument

The lookup is always done by name in some Map<String, Function> "pool". The number of pools, their inheritance is not likely to change by this or #11686 changes.

I like the idea of defaulted/implicit self argument. It should allow us to make sure that all methods/functions in the pools are always of following FunctionSchema:

fn self[=T] arg1[=def1] arg2[=def2] arg3[=def3] ...
  • if fn is an instance method, then the first argument is self and it doesn't have default value
  • if fn is a static method, then the first argument is self and it has default value set to the defining type T

By adhering to this logic, we avoid the duplication of functions (described in #11686) - each function will be defined just once. It will be looked up and based on the type of invocation either implicit self=obj will be pre-applied or not.

PS: Sounds reasonable, but ... it would be good to formalize it with some strict apparatus, otherwise it is clear we'll miss some nuances (as usually).

Copy link
Member Author

@Akirathan Akirathan Sep 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with more formalization, but do you have any suggestions on how such "strict apparatus" may look like?

Copy link
Member

@JaroslavTulach JaroslavTulach Sep 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's ask the public... meanwhile we should rely on simple, well organized tests. This is my current test case:

import Standard.Base.Any.Any
import Standard.Base.Panic.Panic

type Single
    popis self = "Jsem single"

type Val
    private Value x
    
    popis self = "Jsem to "+self.x

Any.popis self = "Kdokoli"


test_instance val:Val =
    i1 = Single.popis # yields Kdokoli WRONG: #13892
    i2 = Val.popis # yields Kdokoli
    i3 = val . popis # yields Jsem to já
    [ i1, i2, i3 ]

test_static val:Val =
    s1 = Any.popis val # yields Kdokoli
    s2 = Any.popis self=val # yields Kdokoli
    s3 = Panic.recover Any <| Val.popis val # errors with Not_Invokable.Error 'Kdokoli' WRONG!?
    s4 = Panic.recover Any <| Val.popis self=val # errors with Not_Invokable.Error 'Kdokoli' WRONG!?
   
    [ s1, s2, s3, s4 ]

main =
    val = Val.Value "já"

    t1 = test_instance val
    t2 = test_static val
    
    [t1, t2]

there are two errors as far as I can say. Are there any tests related to this instance/static behavior already? Are are they runtime-integration-tests? Can we group them next to each other, add this new one and constantly add new and new?


## Specificity

In order to determine which of the potential dispatch candidates is the correct
Expand Down Expand Up @@ -162,3 +182,54 @@ main = Test.simplify
When invoking a method on _module object_ its _module static methods_ take
precedence over _instance methods_ defined on `Any`. Thus a module serves
primarily as a _container for module (static) methods_.

### Diagram of method resolution and invocation on `Any`

This section describes the _special_ method resolution and invocation on `Any`
type. More specifically, it describes when a `self` argument is implicitly
provided and what kind of `self` it is.

Considering:

```
@Builtin_Type
type Any
to_text self = ...
...

type T
method self = "T.method:" + self.to_text

Any.method self = "Any.method:" + self.to_text
```

where `T.method` "overrides" `Any.method`, the method resolution and invocation
algorithm can be generally described as follows:

- Is the method called statically? For example like `Any.method ...` or
`T.method ...`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does one find out that the "method is called statically"? By receiver being a Type?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Added this sentence to docs in e426eaf

- No: Continue normal resolution and invocation.
- Yes:
- Is the method defined on `Any`?
- No: Continue normal resolution and invocation.
- Yes:
- Is it called on `Any`? For example like `Any.method ...` or
`Any.to_text ...`.
- Yes:
- Method is resolved on `Any` type and invoked with prepended
`self=Any` argument. Which means that `Any.method` is equivalent
to `Any.method Any`, which is equivalent to `Any.method self=Any`.
- No:
- Is the method _overriden_ in the type on which it is called?
- Yes:
- This means that we are calling, e.g., `T.method`.
- Method is resolved on `T` type and invoked with prepended
`self=T` argument.
- Which means that `T.method` is equivalent to `T.method T`,
which is equivalent to `T.method self=T`.
- No:
- This means that we are calling, for example, `T.to_text`.
- Method is resolved on `Any` type and invoked with prepended
`self=T`
- Which means that `T.to_text` is equivalent to `Any.to_text T`,
which is equivalent to `Any.to_text self=T`.
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,28 @@ public void resolveStaticMethodFromAny() {
assertSingleSelfArgument(func);
}

@Test
public void resolveStaticMethod_OverriddenFromAny() {
var myTypeVal =
ctxRule.evalModule(
"""
from Standard.Base import Any

type My_Type
to_display_text self = "42"

main = My_Type
""");
var myType = unwrapType(myTypeVal);
var symbol = UnresolvedSymbol.build("to_display_text", myType.getDefinitionScope());
var func = methodResolverNode.executeResolution(myType, symbol);
assertThat("to_display_text method is found", func, is(notNullValue()));
assertThat(
"Resolve from My_Type, and not from Any",
func.getName(),
is("My_Type.to_display_text"));
}

@Test
public void resolveInstanceMethodFromMyType() {
var myTypeVal =
Expand All @@ -68,6 +90,29 @@ public void resolveInstanceMethodFromMyType() {
assertSingleSelfArgument(func);
}

@Test
public void resolveInstanceMethod_DefinedBothOnMyTypeAndAny() {
var myTypeVal =
ctxRule.evalModule(
"""
from Standard.Base import Any

Any.method self = 23

type My_Type
method self = 42

main = My_Type
""",
"Module",
"main");
var myType = unwrapType(myTypeVal);
var symbol = UnresolvedSymbol.build("method", myType.getDefinitionScope());
var func = methodResolverNode.executeResolution(myType, symbol);
assertThat("method is found", func, is(notNullValue()));
assertThat(func.getName(), is("My_Type.method"));
}

@Test
public void resolveStaticMethodFromMyType() {
var myTypeVal =
Expand Down
16 changes: 14 additions & 2 deletions test/Base_Tests/src/Semantic/Any_Spec.enso
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,18 @@ add_specs suite_builder =
(1 != 1) . should_be_false

suite_builder.group "Any's methods" group_builder->
group_builder.specify "should not be overridable when called statically" <|
group_builder.specify "can be overridden when called statically" <|
My_Type.Value 33 . x . should_equal "Any:(My_Type.Value 33)"
With_X.Value 44 . x . should_equal "With_X:(With_X.Value 44)"
With_Y.Value 44 . x . should_equal "With_Y:With_Y(44)"
My_Type.x . should_equal "Any:My_Type"
With_X.x . to_text . should_equal "Any:With_X"
With_X.y.should_be_a Function
With_Y.x . to_text . should_equal "Any:With_Y"
With_Y.y.should_be_a Function
With_X.to_text . to_text . should_equal "With_X"
With_Y.to_text . to_text . should_equal "With_Y"
Any.x . to_text . should_equal "Any:Any"
Any.x Any . to_text . should_equal "Any:Any"
Any.x self=Any . to_text . should_equal "Any:Any"
Any.x self=With_X . should_equal "Any:With_X"
Any.x self=With_Y . should_equal "Any:With_Y"
Expand Down Expand Up @@ -108,6 +108,18 @@ add_specs suite_builder =
Vector.is_empty self=[] . should_be_true
Vector.is_empty [] . should_be_true

group_builder.specify "overridden methods from Any called statically on different types should resolve to overridden method and have defaulted self argument" <|
# With_X.x overrides Any.x
With_X.x . should_equal "With_X:With_X"
With_X.x With_X . should_equal "With_X:With_X"
With_X.x self=With_X . should_equal "With_X:With_X"
Any.x self=With_X . should_equal "Any:With_X"

# There is no My_Type.x - My_Type.x does not override Any.x
My_Type.x . should_equal "Any:My_Type"
Any.x My_Type . should_equal "Any:My_Type"
Any.x self=My_Type . should_equal "Any:My_Type"


main filter=Nothing =
suite = Test.build suite_builder->
Expand Down
Loading