Support Forward Declarations

Forward Declarations for def and class

I propose that Python formally allow forward declarations of functions and classes using the same “empty” syntax currently valid within typing.Protocol or stub files (.pyi). Specifically, a definition using the ellipsis literal ... would serve as a promise to the compiler that the full implementation follows later in the same module.

# Forward declaration
def second_entry_point(*args) -> Any: ...

def first_entry_point(x: Any) -> Any:
    # Logic that references second_entry_point
    return second_entry_point(x)

# Actual implementation
def second_entry_point(*args) -> Any:
     # Implementation details here

The Rationale

Python’s execution model is strictly top-down. While this is straightforward, it forces a specific organizational “gravity” on code:

  1. Readability: We are often forced to place “leaf” functions at the top of a file and the main entry points at the bottom. This is the opposite of how most people read. We want to see the high-level intent first and the implementation details “way at the bottom.”

  2. Circular Logic: While Python handles late binding for function bodies, certain decorators, type-hint linters, and architectural patterns benefit from the symbol existing in the namespace before the heavy implementation block is defined.

  3. Same Syntax: This proposal requires no syntax changes. The def name(): ... is already valid syntax in Python; it just currently results in a function object that does nothing or a parse-time error.

Proposed Behavior

  1. Declaration: A def or class block containing only ... after : is treated as a forward declaration. A promise that a body with the exact same signature will be provided later, in the same module.
  2. Fullfilment: The compiler/interpreter must ensure that a symbol declared this way is followed by a “concrete” definition within the same module.
  3. Errors: If a forward declaration is made but no implementation is provided by the time the module is executed, the interpreter will raise a TypeError over the missing body.
  4. Namespace: The first declaration reserves the name in the namespace. The second definition updates the existing entry rather than shadowing it, honoring any decorators applied to the first declaration (?).

Comparison with Protocols

We already use this syntax in Protocols:

class Plugin(Protocol):
    def run(self) -> None: ...

Extending this to other declarations brings a consistent “interface-first” capability to regular Python modules. It allows developers to “outline” a module at the top as a form of live documentation—and fill in the guts below.

Unknowns

  1. Where do docstrings go?
  2. Do we really want decorators applied on the first declaration?
  3. There are use cases for fulfilling forward declarations in another module, but the bookkeeping is minimal only if the fulfillment happens in the same module.
  4. What to do with @dataclass and other decorators for classes.
  5. I tried using typing.overload, but the semantics are not those of a forward declaration
  6. Should a @forward decorator be required? Would it be useful?
1 Like

You can use your exact code today. Does this cause problems?

# Forward declaration
def second_entry_point(*args) -> Any: ...

def first_entry_point(x: Any) -> Any:
    # Logic that references second_entry_point
    return second_entry_point(x)

# Actual implementation
def second_entry_point(*args) -> Any:
     # Implementation details here

Or, you can simply omit the first declaration. Doesn’t this work?

def first_entry_point(x: Any) -> Any:
    # Logic that references second_entry_point
    return second_entry_point(x)

def second_entry_point(*args) -> Any:
     # Implementation details here

Maybe there are more complex cases. Can you show us an example where you needed the forward declaration? You mentioned “certain decorators, type-hint linters, and architectural patterns” but you gave no examples.

10 Likes

I’m guessing you want to forward declare a decorator…

def some_decorator(f):  # I want to write this at the bottom
    ...
    return f

@some_decorator
def foo():
    do something

… or maybe a callback?

def some_callback():  # I want to write this at the bottom
    do something

thread = threading.Thread(target=some_callback)

Those are the only examples I can think of where you can’t already write functions in any order.

Not sure how forward declarations would make sense in either of those cases though.

A Python module isn’t just a soup of definitions. It’s still a script which is executed sequentially from beginning to end – even if the only execution is defining some functions. Expecting Python to implicitly hop over parts of code it can’t execute yet because it essentially has an undefined variable and then come back later to finish the job is quite a break in how Python executes.

7 Likes

Larry Hastings and I proposed this at a Language Summit years ago. There was no appetite for it. Larry designed PEP 649 to largely solve the typing version of this problem.

5 Likes

Instead, you should raise NotImplementedError , which is already supported.

def second(*args):
    raise NotImplementedError

def first(x):
    return second(x)

# # Actual implementation
# def second(*args):
#      # Implementation details here

first(42)
1 Like

Someone replied to a topic you are Watching.

… or maybe a callback?

def some_callback(): # I want to write this at the bottom do
something thread = threading.Thread(target=some_callback) |

In that case you could use a stub that delegates to the implementation.

def some_callback(): # I want to write this at the bottom
the_actual_callback() thread = threading.Thread(target=some_callback)
def the_actual_callback(): … |

Even with a callback, are we really spawning threads at the top level of the module? Wouldn’t you rather have this?

def main():
    thread = threading.Thread(target=some_callback)

def some_callback():  # I want to write this at the bottom
    do something

main()
1 Like

A typical scenario is that several classes are declared in the same module because they mention each other and there’s no desire to resolve import cycles.

When the classes implement a large interface, the second class in the module may be visually lost at line > 500. A forward declaration would make it evident from the module heading what is contained there.

Most modern IDEs support a “structure view” which reveals what is in a module even if it’s > 2500 lines long.

Forward declarations add no new functionality, but just make it easier on the reader. Python already supports redefining any name in its core, but all linters will flag the redefinition.

Because the proposal requires no changes in syntax, it would be just a statement of purpose to be honored by linters.

Because def f(): ... is equivalent to def f(): pass, both valid Python, it’s up to core developers if they want to warn on err on a definition of ... with no concrete redefinition.

Linters warn if an implementation of a Protocol (which is different from an abstract class) is missing entries, but Python doesn’t care unless @runtime_checkable is used.

As I mentioned, I tried abusing typing.overload, but the linters complaint that my intentions are not up to norm.

1 Like

What happens if you call the function before its body is declared? That would make it not backward compatible.

1 Like

If the goal is helping the reader of the module, how about using a comment at the top of the module describing the contents?

If you want to use actual code as a forward declaration, you can use a linter comment to disable the linter complaining about the redefinition.

1 Like

I would argue to consider moving the thing that should be at the bottom to another file then import at the top.

1 Like

This is a breaking change.
Presently, users can define functions with nothing but ... in their body, and call those functions.

2 Likes

There’s no breaking change. I wrote an example, and all I needed to do for it to be “correct” was to tell the linters to not report errors.

This program compiles and runs today:

# noinspection PyOverloads
def f() -> int: ...  # pyright: ignore[reportRedeclaration]  # ty:ignore[empty-body]


def f() -> int:  # noqa: F811
    return 0
1 Like

I already mentioned that for mutual references the easy (probably the correct) thing to do is to declare the entities in the same module (when declared in different modules Python will have to suspend parsing one of the modules to get the dependencies right).

There are object model designs that are logical with mutual references. There are also well-known algorithms that have a logical implementation with recursion over two functions that call one another. Many times there are compelling reasons to place definitions in the same module.

I must have misunderstood. You said:

Errors: If a forward declaration is made but no implementation is provided by the time the module is executed, the interpreter will raise a TypeError over the missing body.

I thought that meant that this code would raise a TypeError:

def f(): ...

f()

Though now that I read it more closely, you said “by the time the module is executed” as if this alone would raise an error:

def f(): ...
# end of file.

Can you clarify what situations would raise a TypeError?

3 Likes

If you call function f right after its forward declaration, will it return None , raise an exception, or return 0 ?

def f(): ...

f()  # ??

def f():
    return 0

Both of these options are not backward compatible because both are currently valid code that do not raise exceptions. The largest concern I have with @apalala’s proposal is it changes the semantics of valid code. I think a new syntax that is currently invalid is required to add this feature.

I’ve written functions, “right” or “wrong”, that use ellipses instead of pass; I prefer ‘…’ to ‘pass’. It might technically be wrong, but functionally it does exactly what is intended.

They express different intent. def func(): pass means “this function does nothing”; def func(): ... means “I haven’t written this function’s body yet”. They do the same thing NOW, but the ellipsis indicates that something isn’t finished yet.

2 Likes

Raising anything does change the semantics of module loading in Python.

I draw back the part of the proposal in which the Python compiler or the interpreter have to do anything differently or need any changes.

If this proposal makes it into a PEP, then it would be only about the expected behavior of type linters, which already jump on PEP8 over code that is completely valid Python.

Exactly.

I think that this conversation has converged to no changes needed in the Python compiler or interpreter, and a nice-to-have for linters.

  1. Do not consider a redefinition the use of the same name if the first definition used ....
  2. Warn if a definition of ... doesn’t have another definition in the same block.
  3. Use pass and not ... for definitions meant to be empty or do-nothing.
2 Likes