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.AbstractVarandstructional.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:
Structsubclasses only inherit from otherStructs;- abstract classes must have names starting with
Abstractor_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
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
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.