Skip to content

Interfaces

Interfaces define contracts that types must satisfy.

interface IDrawable:
    """Interface for drawable objects."""

    # Preferred abstract method syntaxes:

    # 1. Inline ellipsis syntax (preferred)
    def draw(self): ...

    # 2. Block ellipsis syntax
    def get_bounds(self) -> tuple[float, float, float, float]:
        ...

    # 3. Body-less syntax (DEPRECATED - emits SPY0464 warning)
    def resize(self, width: float, height: float)

# Implementation
class Circle(IDrawable):
    radius: float
    x: float
    y: float

    def __init__(self, x: float, y: float, radius: float):
        self.x = x
        self.y = y
        self.radius = radius

    def draw(self):
        print(f"Drawing circle at ({self.x}, {self.y})")

    def get_bounds(self) -> tuple[float, float, float, float]:
        return (self.x - self.radius, self.y - self.radius,
                self.radius * 2, self.radius * 2)

    def resize(self, width: float, height: float):
        self.radius = min(width, height) / 2

Interface Rules: - All methods are implicitly abstract unless they have a body that is not ... (ellipsis). - Abstract method syntaxes: 1. Inline ellipsis (preferred): def method(self) -> T: ... — Colon + ellipsis on same line 2. Block ellipsis: Colon, newline, indent, then ... — parenthesized as (...) means the same thing, since grouping is transparent (see Ellipsis Literal) 3. Body-less (deprecated, SPY0464 warning): def method(self) -> T — No colon, no body - Body conventions: ... = abstract (no implementation), pass = concrete empty body (default implementation) - Methods with an actual body (even just a pass statement) become the default implementation. - Implementing types must provide all methods that don't have a default implementation and can override the implementation of those that do have a default implementation.

interface ISomeInterface:
    # This method is abstract (the use of the ellipsis literal signals this)
    def method(self):
        ...

    # This method has a default implementation with an empty body
    def method2(self):
        pass

Non-Void Methods and Empty Bodies:

For non-void methods in interfaces (or anywhere), using pass alone is a compile error because the method must return a value:

interface IFoo:
    # ✅ OK - abstract method (no implementation required)
    def get_value(self) -> int:
        ...

    # ❌ ERROR - non-void method body must return a value
    def get_other(self) -> int:
        pass  # Compile error: missing return statement

    # ✅ OK - provides a default return value
    def get_default(self) -> int:
        return 0

The distinction is: - No colon/body → abstract, no implementation (interface methods only) - ... (ellipsis) → abstract, no implementation - pass → empty body, valid only for -> None methods as a default implementation - For non-void methods, use body-less, ... (abstract), or provide a return statement

Implementation - ✅ Native - Direct mapping to C# interface.

Generic Interfaces

interface IContainer[T]:
    def add(self, item: T) -> None: ...
    def get(self, index: int) -> T: ...
    def count(self) -> int: ...

One instantiation per generic interface

A type may implement a generic interface at most once — at exactly one set of type arguments. If a type reaches the same generic interface at two distinct instantiations (through explicit declarations, base classes, or synthesized interfaces from dunder methods), the compiler refuses with SPY0607:

interface IA[T]:
    def get(self) -> T: ...

class B(IA[int]):
    def get(self) -> int:
        return 1

class D(B, IA[str]):  # SPY0607: IA[int] via B and IA[str] explicit
    def get(self) -> str:
        return "hello"

The same instantiation via multiple paths is accepted (the diamond is harmless):

interface IA[T]:
    def get(self) -> T: ...

interface IB(IA[int]):
    pass

interface IC(IA[int]):
    pass

class D(IB, IC):  # OK — IA[int] via both paths
    def get(self) -> int:
        return 42

Conflicts from dunder-synthesized interfaces follow the same rule:

class Base:
    def __eq__(self, other: str) -> bool:
        return False

class Derived(Base):
    def __eq__(self, other: int) -> bool:  # SPY0607
        return False

Interface Inheritance

Interfaces can extend other interfaces:

interface ISerializable:
    """Base interface for serialization."""
    def serialize(self) -> str: ...

interface IJSONSerializable(ISerializable):
    """Extends ISerializable with JSON-specific methods."""
    def to_json(self) -> str: ...
    def from_json(self, json: str): ...

class User(IJSONSerializable):
    """Must implement all methods from both interfaces."""
    username: str

    def serialize(self) -> str:
        return self.username

    def to_json(self) -> str:
        return f'{{"username": "{self.username}"}}'

    def from_json(self, json: str):
        pass  # Parse and update

Default Method Implementations

Interfaces can provide default implementations for methods. Implementing types inherit the default unless they provide their own implementation.

For comprehensive coverage of default methods including conflict resolution, comparison with abstract classes, and multiple interface conflicts, see Interface Default Methods.

interface ILogger:
    def log(self, message: str):
        """Log a message. Must be implemented."""
        ...

    def log_info(self, message: str):
        """Log an info message. Has default implementation."""
        self.log(f"[INFO] {message}")

class ConsoleLogger(ILogger):
    # Must implement abstract method
    def log(self, message: str):
        print(message)

    # Inherits log_info default

Implementation - ✅ Native - Direct mapping to C# default interface methods (C# 8.0+).

Dunder Methods in Interfaces

Standard Library Only:

Only interfaces defined in the Sharpy standard library can declare dunder methods. User-defined interfaces cannot declare dunders.

The actual protocol interfaces in Sharpy.Core are:

  • ISized -- int Count { get; } -- synthesized when __len__ is present
  • IBoolConvertible -- bool IsTrue { get; } -- synthesized when __bool__ is present
  • IReverseEnumerable<T> -- IEnumerator<T> GetReverseEnumerator() -- synthesized when __reversed__ is present

These interfaces are synthesized during inheritance resolution when the corresponding dunder method is present in the class body; they are visible to the type checker and announced by an SPY1001 info diagnostic. Users do not import or explicitly implement these interfaces.

# ✅ Standard library protocol interface (implicit via dunder)
class MyCollection:
    _items: list[int]

    def __len__(self) -> int:
        return len(self._items)
    # Compiler implicitly adds ISized to the base list

    def __bool__(self) -> bool:
        return len(self._items) > 0
    # Compiler implicitly adds IBoolConvertible to the base list

# ✅ Synthesized interface visible to the type checker
def size(s: ISized) -> int:
    return len(s)

def main():
    c = MyCollection()
    print(size(c))    # works — Bag implements ISized via __len__

A synthesized protocol interface is non-generic, and a constructed generic host carries it too: a Box[int]'s __len__ synthesizes ISized on the host, so the constructed type satisfies a plain ISized slot even though Box itself is generic (see #1865).

# A constructed generic host carries its synthesized interface into a non-generic slot.
class Box[T]:
    _items: list[T]

    def __init__(self):
        self._items = []

    def add(self, item: T) -> None:
        self._items.append(item)

    def __len__(self) -> int:
        return len(self._items)

def total(s: ISized) -> int:
    return len(s)

def main():
    b: Box[int] = Box[int]()
    b.add(1)
    b.add(2)
    print(total(b))    # 2 — Box[int]'s synthesized ISized flows into the non-generic slot
# ❌ ERROR: User-defined interface cannot declare dunders
interface IMyProtocol:
    def __custom__(self) -> int:    # ERROR: dunder methods not allowed
        ...

    def __len__(self) -> int:       # ERROR: dunder methods not allowed
        ...

Rationale:

  1. Controlled semantics: Dunder methods have special meaning and compiler integration. Restricting them to the standard library ensures consistent behavior.

  2. Operator dispatch: The compiler needs to know exactly which dunders exist and what they do. User-defined dunders would break this model.

  3. .NET interop: Standard library interfaces map to well-known .NET interfaces (e.g., IEnumerable).

Context managers via dunder protocol:

User code can implement the context manager protocol by defining __enter__ and __exit__ dunder methods directly on a class (no interface import needed):

def acquire_resource() -> int:
    return 7

def release_resource(handle: int) -> None:
    print(f"released {handle}")

class ManagedResource:
    _handle: int

    def __init__(self):
        self._handle = acquire_resource()

    def __enter__(self) -> ManagedResource:
        return self

    def __exit__(self):
        release_resource(self._handle)

def use(resource: ManagedResource) -> None:
    print("using")

def main():
    with ManagedResource() as resource:
        use(resource)    # using, then: released 7

See Context Managers for the full protocol, including the 3-arg exception-aware __exit__ form.

Implementation - Compiler validates that dunder declarations only appear in whitelisted standard library interfaces. - Protocol interfaces (ISized, IBoolConvertible, IReverseEnumerable<T>) are implicitly synthesized by the emitter.

Member Names and Access

Interface members are public in .NET, so the underscore access convention (_name protected, __name private — see decorators.md) cannot apply to them. A method, property or event named _m or __m in an interface — top-level or nested — is a compile-time error (SPY0707):

interface IShape:
    def _area(self) -> float: ...    # SPY0707 — interface members are public

To keep an underscore spelling, backtick-escape the name. An escaped name is a literal: it carries no access convention, so `_area` is a PUBLIC member spelled _area. The implementing class or struct declares it with the same escaped spelling, and every use is spelled the same way:

interface IShape:
    def `_area`(self) -> float: ...

class Square(IShape):
    side: float

    def __init__(self, side: float):
        self.side = side

    def `_area`(self) -> float:
        return self.side * self.side

def main():
    s: IShape = Square(3.0)
    print(s.`_area`())            # 9.0
    print(Square(2.0).`_area`())  # 4.0

The escape means "no convention" on every host, not only in interfaces: a class or struct member declared `_m` is public too. Implementing (or overriding) an escaped member with the bare spelling — or the reverse — names a different member, and is refused by name: SPY0325 for an interface method, SPY0248 for an override ("declared as `_area`; implement it with the same spelling").

Implementation - ✅ Native — the escaped name is emitted verbatim and public on the interface and on every implementer (#2033).

Constants

Interfaces can declare compile-time constants, accessed through the interface name:

interface IConfig:
    const VERSION: int = 1

class App(IConfig):
    def show(self):
        print(IConfig.VERSION)

def main():
    app = App()
    app.show()       # 1
    print(IConfig.VERSION)  # 1

A constant whose initializer is a compile-time expression (int, str, float, bool literal or a const reference) emits a C# const; a non-constant initializer (e.g. a function call) emits static readonly. Non-const interface fields remain properties.

See Also