Skip to content

feat[lang]: add abstract methods#4875

Open
Sporarum wants to merge 154 commits intovyperlang:masterfrom
Sporarum:abstract-methods
Open

feat[lang]: add abstract methods#4875
Sporarum wants to merge 154 commits intovyperlang:masterfrom
Sporarum:abstract-methods

Conversation

@Sporarum
Copy link
Collaborator

@Sporarum Sporarum commented Mar 5, 2026

What I did

Implement "initializes-based proposal v2" of #4730
With the change that calling an abstract method requires usesing its module.
This forces the abstract module to be initializes, and hence overridden, somewhere.

Note that this PR does not add docs for this feature !
It does not either change venom in any way !
(not even assertions to make sure it crashes on abstract methods)

How I did it

How to verify it

Commit message

this commit add abstract methods to vyper and a way to override them. it
implements the abstract methods VIP as defined in "initializes-based
proposal v2" with the one change that using an abstract method requires
uses-ing its module. calls to abstract functions are treated as if they
were calls to their override. this requires decoupling the call graph
from the import graph (and any method dealing with either). this commit
also heavily tests the new feature, and hopefully all edge cases are
covered. some refactoring was performed to facilitate the
implementation: to make namespaces easier to reason about and
manipulate, Namespace is refactored to be immutable and uses instead a
contextvar to be passed implicitly and updated. to make analyzing nodes
easier, NodeAccumulator is added, it works similarly to
VyperNodeVisitorBase but is designed with immutability in mind, as such
its methods return an accumulator value instead of mutating state. to
make reasoning about typing easier, is_supertype_of, is_subtype_of and
is_equivalent_to methods are added to vyper types. the original,
compare_type, was sometimes used to check both equivalence and
subtyping. when all usages are disambiguated, it should be removed.

Description for the changelog

(Not sure what the format is for these, let me know if this can be improved)

  • Add @abstract and @override decorators to make modules overridable

Cute Animal Picture

Put a link to a cute animal picture inside the parenthesis-->

Sporarum added 30 commits March 5, 2026 15:02
Seems to have been always empty anyways
The result from the original was never used, and should not have been anyways, as it only extracts a single terminating node, when there can be multiple
It seems correct, but I am not familiar enough with these complicated classes to be sure there are no mistakes
…order

Now functions in CompilerData only call methods before them, so you can have a clearer idea of what has and hasn't been executed
Note: This does not guarantee every method before the one you are on will have been called!

if self.func.return_type and not _is_terminated:
raise FunctionDeclarationException(
f"Missing return statement in function '{self.fn_node.name}'", self.fn_node
Copy link
Member

Choose a reason for hiding this comment

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

now i see why it was originally find_terminating_node, i guess the intent was to have a hint pointing to exactly which branch was not terminated

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Should I refactor it to do that ? (Or add a TODO)

The issue is that it's always possible to add a return at the very end of a block, so there's not really a location to point to

Comment on lines +432 to +438
if varaccess is not None:
self.loop_variables.append(varaccess)
try:
yield
finally:
self.loop_variables.pop()

def visit(self, node):
super().visit(node)
if varaccess is not None:
self.loop_variables.pop()
Copy link
Member

Choose a reason for hiding this comment

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

at this point i would rewrite the entire block so that the branching is clear

Suggested change
if varaccess is not None:
self.loop_variables.append(varaccess)
try:
yield
finally:
self.loop_variables.pop()
def visit(self, node):
super().visit(node)
if varaccess is not None:
self.loop_variables.pop()
if varaccess is None:
yield
return # if this is not possible then use an else branch
self.loop_variables.append(varaccess)
try:
yield
finally:
self.loop_variables.pop()

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done

)
is_abstract = func._metadata["func_type"].is_abstract

if is_abstract:
Copy link
Member

Choose a reason for hiding this comment

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

do we visit the bodies of abstract functions?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Not anymore, see cf802b8

if root_module_info is not None:
self.func.mark_used_module(root_module_info)

# Note: We don't check what the override touches, as this would severely hurt
Copy link
Member

Choose a reason for hiding this comment

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

what?

Copy link
Member

Choose a reason for hiding this comment

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

we can't just not validate it lol, in that case the overriding module is responsible for declaring uses!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The concrete methods in an abstract module are validated !

What we don't do is look at the override of abstract methods (who live in another module), and use their body to add requirements to the abstract module

See test_overriding_module_can_initialize_state for an example of what would happen otherwise:

# abstract module:

# if the following was needed, it would severely limit the usefulness of the feature
# uses: stateful

@abstract
def process() -> uint256: ...

# override module:

import stateful
import b_module

initializes: stateful
initializes: b_module

@override(b_module)
def process() -> uint256:
    stateful.increment()
    return stateful.get_counter()

The root module (compilation target) must be the last element in `modules`.
"""
return _analyze_module_r(module_ast, module_ast.is_interface)
# The root module is always the last element, since dependencies are sorted
Copy link
Member

Choose a reason for hiding this comment

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

this comment contradicts the docstring

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Both updated, what's going on should be clearer now

Let me know if it's still confusing

Analyze Vyper module ASTs, add all module-level objects to the namespace,
type-check/validate semantics and annotate with type and analysis info.

The root module (compilation target) must be the last element in `modules`.
Copy link
Member

Choose a reason for hiding this comment

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

kind of weird, can we enforce this in a type safe way?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Not really, but it is a logical consequence of modules being sorted such that dependencies always come before dependents



def analyze_module(module_ast: vy_ast.Module) -> ModuleT:
def analyze_modules(modules: OrderedSet[vy_ast.Module]) -> ModuleT:
Copy link
Member

Choose a reason for hiding this comment

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

why is the api changing?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Because analysis is now done in multiple passes (no way around it), so we need to have the list of modules (in post-order)

And it's already computed as part of another phase

raise CallViolation(msg, func, g.ast_def)

def _compute_reachable_sets(module_ast: vy_ast.Module):
with override_global_namespace(module_ast._metadata["namespace"]):
Copy link
Member

Choose a reason for hiding this comment

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

why?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Removed, was unnecessary

# note that we don't just copy the namespace because
# there are constructor issues.
_ns.update({k: self.namespace[k] for k in self.namespace._scopes[-1]}) # type: ignore
_ns._scopes = self.namespace._scopes.copy()
Copy link
Member

Choose a reason for hiding this comment

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

why?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Because it was broken before

Using a module namespace would instantly fail with "self is already a member" (or something like that) because self gets added when scopes is empty

Semantically I also don't see why we wouldn't copy the scopes as part of a copy of a namespace


# Return type validation

if return_type_abstract:
Copy link
Member

Choose a reason for hiding this comment

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

this is a lot of nesting to just output some strings

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Heavily simplified, should be much clearer now

action = "add a" if abstract_t.nonreentrant else "remove the"
discrepancies.append(
FunctionDeclarationException(
f"Override reentrancy mismatch: Override {_is(override_t.nonreentrant)} "
Copy link
Member

Choose a reason for hiding this comment

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

maybe " is [reentrant/nonreentrant] but is [nonreentrant/reentrant]"

like the error message is also wrong because we can have @reentrant decorator

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done

I also removed the hint for simplicity, let me know if you want me to add one back

)


def _structurally_equivalent_any_r(v1: Any, v2: Any) -> bool:
Copy link
Member

Choose a reason for hiding this comment

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

why is this necessary?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

For the list elements, nodes can contain lists which contain things that are not nodes

Or do you mean I should inline _structurally_equivalent_node_r so there's a single method ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants