Skip to content

Structs¤

Structs are specifically there to enable writing codebases that want to use 'abstract/final rules': amongst other things, that every class must be either abstract (can be subclassed but not instantiated) or final (can be instantiated but not subclassed).

This is a style of Python programming heavily inspired by Haskell, Julia, and Rust. Most particularly we focus on being immutable, and on defining interfaces. If you haven't already, take a look at the abstract/final guide.


structional.Struct ¤

Features:

Subclasses of Struct:

  • are frozen dataclasses;
  • support abc.abstractmethod, structional.AbstractVar and structional.AbstractClassVar.
  • support __check_init__. When a class is instantiated, then this method is called on the class and on all of its parent classes. This provides a place to check that e.g. an integer field is non-negative.
  • have pretty-printed __repr__s (using Wadler–Lindig).

Abstract/final rules:

The real reason for using Structs is that they are written to implement 'abstract/final rules': that is, every class must either be abstract (can be subclassed but not instantiated) or final (can be instantiated but not subclassed). This is sometimes equivalently known as 'concrete implies final'.

Specifically, we check that:

  • Struct subclasses only inherit from other Structs;
  • abstract classes must have names starting with Abstract or _Abstract;
  • abstract classes cannot be instantiated.
  • concrete classes cannot be subclassed (i.e. they are final).
  • concrete methods cannot be overridden.

Whether a struct is abstract or final is automatically inferred from the presence of abstract methods or AbstractVar/AbstractClassVar. In the unusual scenario in which an abstract struct is desired without any of those, then the keyword argument is_abstract=True can be used:

class AbstractStruct(Struct, is_abstract=True):
    ...

You should not user super() in conjunction with Structs. They are designed to enable a different design pattern than co-operative multiple inheritance (which as above we file under "OOP-badness").

Example

class AbstractOptimizer(Struct):
    learning_rate: AbstractVar[float]

    def __check_init__(self):
        if self.learning_rate <= 0:
            raise ValueError("You will not be going to space today.")

    @abc.abstractmethod
    def make(self, params: list[torch.nn.Parameter]) -> torch.optim.Optimizer:
        ...

class SGD(AbstractOptimizer):
    learning_rate: float

    def make(self, params: list[torch.nn.Parameter]) -> torch.optim.Optimizer:
        return torch.optim.SGD(params, learning_rate=self.learning_rate)

structional.AbstractVar ¤

Used to mark an abstract instance attribute, along with its type. Used as:

class Foo(Struct):
    attr: AbstractVar[bool]

An AbstractVar[T] must be overridden by an attribute annotated with AbstractVar[T], AbstractClassVar[T], ClassVar[T], T, or a property returning T.

This makes AbstractVar useful when you just want to assert that you can access self.attr on a subclass, regardless of whether it's an instance attribute, class attribute, property, etc.

Attempting to instantiate a class with an unoveridden AbstractVar will raise an error.

Example

class AbstractX(Struct):
    attr1: int
    attr2: AbstractVar[bool]

class ConcreteX(AbstractX):
    attr2: bool

ConcreteX(attr1=1, attr2=True)

Info

AbstractVar does not create a dataclass field. This affects the order of __init__ argments. E.g.

class AbstractX(Struct):
    attr1: AbstractVar[bool]

class ConcreteX(AbstractX):
    attr2: str
    attr1: bool
should be called as ConcreteX(attr2, attr1).

structional.AbstractClassVar ¤

Used to mark an abstract class attribute, along with its type.

Used as:

class Foo(Struct):
    attr: AbstractClassVar[bool]

An AbstractClassVar[T] can be overridden by an attribute annotated with AbstractClassVar[T], or ClassVar[T]. This makes AbstractClassVar useful when you want to assert that you can access cls.attr on a subclass.

Attempting to instantiate a class with an unoveridden AbstractClassVar will raise an error.

Example

from typing import ClassVar

class AbstractX(Struct):
    attr1: int
    attr2: AbstractClassVar[bool]

class ConcreteX(AbstractX):
    attr2: ClassVar[bool] = True

ConcreteX(attr1=1)

Info

AbstractClassVar does not create a dataclass field. This affects the order of __init__ argments. E.g.

class AbstractX(Struct):
    attr1: AbstractClassVar[bool]

class ConcreteX(AbstractX):
    attr2: str
    attr1: ClassVar[bool] = True
should be called as ConcreteX(attr2).

Known issues¤

Due to a Pyright bug (#4965), this must be imported as:

if TYPE_CHECKING:
    from typing import ClassVar as AbstractClassVar
else:
    from structional import AbstractClassVar

structional.is_abstract_struct(cls: type[structional.Struct]) -> bool ¤

Return whether this struct is abstract or concrete.

structional.replace(struct: ~_Struct, **kwargs) -> ~_Struct ¤

Replace fields in a Struct. (Like dataclasses.replace, but works even with custom __init__ methods.)

To replace deeply-nested items, see structional.tree.replace.

Arguments:

  • struct: the struct to replace a field on.
  • **kwarg: keyword names are the names of the fields to replace; keyword values are the values to set the fields to.

Returns:

The update struct. The original struct is left unchanged.

Faq

Note that no typechecking is performed when doing replace, nor are __init__ or __check_init__ called. If this is important to you then you should perform this check afterwards.

The reason for this is because in some use-cases it is important that structs be polymorphic over their leaf types, e.g. when attaching metadata. This isn't super common in business code, but it's useful in more 'infrastructure' code to be able to create a 'matching' structure with different leaf values. For example, maybe you have MyData(x=array(...), y=array(...)), and then to determine whether you should save each value to disk then you'd like to construct a matching MyData(x=True, y=False). This general idea extends to a lot of other use-cases: replacing leaves with cheap serialized representations; replacing leaves with async future objects that will become those leaves in the future, etc.