Static Python Tutorial
Getting Started
Static Python is still under development, and there are a lot of rough edges, including probably bugs that can crash the interpreter.
The simplest way to run a Static Python script is:
python -m static script.py
This installs the Static Python loader and executes your script. Both
the entry script and any modules it imports that use import __static__
will be compiled through the Static Python pipeline.
Manual loader installation
For more control, you can install the loader yourself by calling
cinderx.compiler.strict.loader.install() in the main module of
your program (before anything else is imported.) Note this means the
main module itself cannot be Static Python. You can also just set the
PYTHONINSTALLSTRICTLOADER environment variable to a nonzero value,
and the loader will be installed for you.
Once you've installed the loader, any module with import __static__
as its first line of code (barring optional docstring and optional
__future__ imports) will be compiled as Static Python. (You can also
use import __strict__ if you just want Strict Module semantics --
immutable modules that can't have side effects at import time --
without Static Python. import __static__ also implies Strict, so you
should never use both.)
Compiling individual files
It is also possible to try out Static Python on simple examples by
running ./python -m compiler --static somemod.py. This will compile
and execute somemod.py as Static Python. Add --dis to also dump a
disassembly of the emitted bytecode, and add --strict to compile the
module as an immutable StrictModule, which enables some additional
optimizations. Enable the CinderX JIT for improved performance. You
can also use -c --output somemod.pyc to output a compiled .pyc
file instead of executing the module.
__static__ imports reference
You may see some unfamiliar imports from a new __static__ module in
modules using Static Python. This reference should clarify their
purpose.
from __static__ import cbool, int8, uint8, int16, uint16, int32, uint32, int64, uint64, char, double
These are primitive types, or C types. They can be used as type
annotations in Static Python modules to signal to the Cinder JIT that it
can use unboxed C types for these values. The static compiler
automatically interprets literals appropriately if in a primitive type
context; e.g. x: cbool = True will create a primitive boolean, not a
Python True (despite the RHS appearing to be Python True),
y: int64 = 3 or int64(3) will create a primitive int64 with value
3, etc. (For literals, this happens in the compiler, so at runtime we
are not creating a boxed Python integer object and then unboxing the
value from it, we directly just create the primitive value.) You can
also at runtime unbox a Python object to a primitive value with e.g.
int64(some_python_int). This might raise TypeError at runtime (if
some_python_int is not actually an int), or it might raise
OverflowError (if the python int is too large for the target primitive
size.)
Some things to know about primitives:
- Performance will generally be better. Creation and reference counting and destruction of Python objects is inherently expensive, and all these costs can be eliminated. In particular arithmetic with primitive values (which can directly translate to assembly arithmetic instructions) will perform much better than arithmetic with dynamic Python objects.
- Unlike Python's numeric types, primitive
intxxanddoublehave limited bit width and can overflow, and you are responsible to avoid this, just as when writing C code. Currently overflow is undefined behavior (usually silent wraparound, in some casesOverflowError); in the future we aim to make it always raiseOverflowError. - To avoid unnecessary creation of expensive Python objects in hot
paths, comparing two primitives produces a
cboolnot a Pythonbool. E.g. ifx: int64 = 3andy: int64 = 4, then afterz = (x == y),zwill have typecbool. - Primitive types cannot mix with Python types or with each other;
their type must always be known exactly by the static compiler. The
compiler will error if you ever try to have a value (even
transiently) of
Uniontype including a primitive type as part of the union. One notable case where this might bite you is due to primitive comparisons producingcbooland the short-circuiting behavior of Python'sandandoroperators. This means that code such asa_prim == b_prim or x_py_int > y_py_intis illegal, because the first compare will produce acbooland the second will produce abool, and the overall expression might produce either one depending whether the first compare is true or false. In such cases you must either explicitly box or unbox some of the compares so that the chain all produce the same type, or split it into separate (maybe nested)ifstatements. - You can pass primitive types as arguments and return values in
function calls within static Python. If a non-static function calls
a Static Python function that takes primitive arguments, it can pass
the Python equivalent type (
floatfordouble,intfor[u]intxx,boolforcbool) and the value will be implicitly unboxed (possibly raisingOverflowError). Similarly, static Python functions returning primitive values to a non-static caller will implicitly box the value to the corresponding Python type. - You can index into lists, tuples etc using primitive integers. In
the general case this is equivalent to
l[box(an_int64)]but for actual builtin sequence types it is optimized in the JIT to avoid the creation of a Python int.
from __static__ import box
The __static__.box function explicitly converts a primitive value to
the corresponding Python type. E.g. x: int = box(an_int64),
y: bool = box(a_cbool), z: float = box(a_double).
from __static__ import CheckedDict, CheckedList
__static__.CheckedDict is a Python dictionary that enforces the
contained types at runtime. E.g. if d: CheckedDict[int, str] then it
will be a runtime TypeError to place a non-int key or non-str value
into d. Within static Python code this is unnecessary since the
compiler will already enforce correct types (and in fact we bypass the
check in this case, so there's also no overhead.) But you can safely
pass a CheckedDict out of Static Python code and into normal Python
code and if it is later passed back into Static Python code, the static
compiler will be able to trust that its keys are definitely ints and its
values definitely strings. (For normal Python containers, which don't do
any runtime enforcement, Static Python always treats their contents as
of dynamic, unknown type.)
Similarly, CheckedList is just like a Python list, except its
contained type is enforced at runtime.
(You may be wondering why the hidden prologue described above doesn't
fully validate the contained types of e.g. a Python dict passed as an
argument to a Static Python function, so that we can trust them. The
answer is that it's far too expensive to do this in general, since it is
necessarily O(n) in the size of the container.)
from __static__ import Array, Vector
__static__.Array is a fixed-size contiguous array of primitive values,
like a C array. __static__.Vector is similar but dynamically sized.
from __static__ import clen
The __static__.clen function gets the len() of a Python object as a
primitive int64. In the general case this is equivalent to
int64(len(obj)), but if obj is a builtin Python list, dictionary,
__static__.CheckedDict, __static__.Array, or __static__.Vector, we
are able to emit a much faster length check without ever creating a
Python int.
from __static__ import crange
Similar to __static__.clen but this allows getting a range with an
unboxed integer. It can be used in a for loop to loop over a set
range without allocating the intermediate integers.
from __static__ import inline
The @inline decorator allows the static compiler to inline a one-line
function directly into its (statically compiled) callers for efficiency.
The function body must consist only of a single return statement.
from __static__ import dynamic_return
The @dynamic_return decorator causes the static compiler to not trust
the annotated return type of a function. It is useful in cases where we
intentionally lie about the return type.
For example, if we return a weakref, or a lazily evaluated string
translation, we may annotate the return value as the weakly-referenced
type, or as a string. In these scenarios, Static Python will try to
ensure the returned object matches the annotation, but that'll fail.
Using dynamic_return is a workaround for such scenarios so
that MyPy or Pyre can still see the more specific annotation, but Static
Python will treat it as dynamically typed.
from __static__ import cast
The __static__.cast() function is similar to typing.cast() in its
usage, but unlike typing.cast() it performs a runtime type check to
validate that the object is in fact of the type you are casting it to,
allowing the static compiler to trust that type. E.g. if
x = cast(int, some_non_static_function()), then the static compiler
will know that x is of type int, even though it doesn't know and
cannot trust the return type of some_non_static_function(). If at
runtime the function returns something that is not an int, the cast
will raise TypeError.
In most cases you shouldn't need __static__.cast(), because the
compiler can handle values of unknown type (it just treats them as
dynamically typed Python objects, same as Python normally would). If you
use an object of unknown type in a place where a specific type is
required, the static compiler will allow you to do so and will
automatically insert a cast to the needed type at that point.
from __static__ import ClassDecorator, TClass
Frequently users will want to use a decorator with a class which can
apply transformations to them such as attaching extra data. The ClassDecorator
and TClass types allow this transformation to be marked in a type-safe way:
from __static__ import TClass
def mydec(cls: TClass) -> TClass:
cls.my_tag = 42
return cls
@mydec
class C:
def __init__(self) -> None:
self.x: int = 42
When the static compiler recognizes that a function implements the __static__.ClassDecorator protocol by accepting and returning TClass it will allow
the decorator to be applied to a static class without invalidating the normal
analysis.
from cinderx import cached_property, async_cached_property
Cached properties are a useful helper for areas where you'd like to produce a value lazily. Cinder includes cached property classes for both async and sync forms. When these are applied to a method in a class the compiler recognizes the decorator and auto-generates the associated storage for the cached version as a slot in the instance.
from cinder import cached_property
class C:
@cached_property
def foo(self) -> int:
return some_expensive_computation()