Constructs¶
Most standard Python constructs are supported in Sonolus.py.
Key Differences¶
- Non-num variables must have a single live definition.
- If there are multiple definitions
var = ...
for a variable, the compiler must be able to determine that a single one is active whenever the variable is used.
- If there are multiple definitions
- Conditional branches may be eliminated if they are determined to be unreachable
- Functions with non-num return types may not return multiple distinct objects
- Most functions returning a non-num value should have a single return at the end
- Destructuring assignment does not support the
*
operator. - Sequence
match
patterns do not support the*
operator. - Mapping
match
patterns are unsupported. - Imports may not be done within functions
- The
global
andnonlocal
keywords are unsupported.
Overview¶
The following constructs are supported in Sonolus.py:
- Expressions:
- Literals:
- Numbers (excluding complex numbers):
0
,1
,1.0
,1e3
,0x1
,0b1
,0o1
- Booleans:
True
,False
- Strings:
'Hello, World!'
,"Hello, World!"
- Tuples:
(1, 2, 3)
- Numbers (excluding complex numbers):
- Operators (if supported by the operands):
- Variables:
a
,b
,c
- Lambda:
lambda a, b: a + b
- Assignment Expression:
(a := b)
- Literals:
- Statements:
- Simple Statements:
- Assignments:
- Simple assignment:
a = b
- Augmented assignment:
a += b
- Attribute assignment:
a.b = c
- Index assignment:
a[b] = c
- Destructuring assignment:
a, b = b, a
- Multiple assignment:
a = b = c = 1
- Annotated assignment:
a: int = 1
- Simple assignment:
- Assert:
assert <condition>, <message>
- Pass:
pass
- Break:
break
- Continue:
continue
- Return:
return <value>
- Import:
import <module>
,from <module> import <name>
(only outside of functions)
- Assignments:
- Compound Statements:
- If:
if <condition>:
,elif <condition>:
,else:
- While:
while <condition>:
,else:
- For:
for <target> in <iterable>:
,else:
- Match:
match <value>:
,case <pattern>:
- Function Definition:
def <name>(<parameters>):
- Class Definition:
class <name>:
(only outside of functions)
- If:
- Simple Statements:
Compile Time Evaluation¶
Some expressions can be evaluated at compile time:
- Numeric literals:
1
,2.5
,True
,False
, ... - None:
None
- Basic arithmetic: for compile time constant operands:
a + b
,a - b
,a * b
,a / b
, ... - Is/Is Not None: for any left-hand operand,
a is None
,a is not None
- Type checks: for any value,
isinstance(a, t)
,issubclass(a, t)
- Boolean operations:
- Negation:
not a
- And
- Both operands are compile time constants:
a and b
- One operand is known to be False:
False and a
,a and False
- Both operands are compile time constants:
- Or
- Both operands are compile time constants:
a or b
- One operand is known to be True:
True or a
,a or True
- Both operands are compile time constants:
- Negation:
- Comparison: for compile time constant operands:
a == b
,a != b
,a > b
,a < b
,a >= b
,a <= b
, ... - Variables assigned to compile time constants:
a = 1
,b = a + 1
, ...
Some values like array sizes must be compile-time constants.
The compiler will eliminate branches known to be unreachable at compile time:
def f(a):
if isinstance(a, Num):
debug_log(a)
else:
debug_log(a.x + a.y)
# This works because `isinstance` is evaluated at compile time and only the first (if) branch is reachable.
# The second (else) branch is eliminated, so we don't get an error that a does not have 'x' and 'y' attributes.
f(123)
Variables¶
Variables can be assigned and used like in vanilla Python.
a = 1
b = 2
c = a + b
Unlike vanilla Python, non-num variables must have a single unambiguous definition when used. Nums have no such restriction.
The following are allowed:
v = Vec2(1, 2) # (1)
v = Vec2(3, 4) # (2)
debug_log(v.x + v.y) # 'v' is valid because (2) is the only active definition
v = 1 # (1)
v = Vec2(3, 4) # (2)
debug_log(v.x + v.y) # 'v' is valid because (2) is the only active definition
v = Vec2(1, 2) # (1)
while condition():
v = Vec2(3, 4) # (2)
debug_log(v.x + v.y) # 'v' is valid because (2) is the only active definition
v = Vec2(1, 2) # (1)
if random() < 0.5:
v @= Vec2(3, 4) # Updates 'v' in-place without redefining it
debug_log(v.x + v.y) # 'v' is valid because (1) is the only active definition
The following are not allowed:
v = Vec2(1, 2) # (1)
if random() < 0.5:
v = Vec2(3, 4) # (2)
debug_log(v.x + v.y) # 'v' is invalid because both (1) and (2) are active
v = Vec2(1, 2) # (1)
while condition():
debug_log(v.x + v.y) # 'v' is invalid because (1) and (2) are active
v = Vec2(3, 4) # (2) redefines 'v' for future iterations
Expressions¶
Literals¶
int
, float
, bool
, str
, and tuple
literals are supported:
a = 1
b = 1.0
c = True
d = 'Hello, World!'
e = (1, 2, 3)
Operators¶
All standard operators are supported for types implementing them. @=
is reserved as the copy-from operator.
a = 1 + 2
b = 3 - 4
c = 5 * 6
d = 7 / 8
e = Vec2(1, 2)
f = e.x + e.y
g = Array(1, 2, 3)
h = g[0] + g[1] + g[2]
(i := 1)
The ternary operator is supported for, but the condition must be a Num
. If the operands are not nums,
the condition must be a compile-time constant or this will be considered an error:
# Ok
a = 1 if random() < 0.5 else 2
b = Vec2(1, 2) if b is None else b
# Not ok
c = Vec2(1, 2) if random() < 0.5 else Vec2(3, 4) # Multiple definitions
If the condition is a compile-time constant, then the ternary operator will be evaluated at compile time:
e = Vec2(0, 0) if e is None else e # Ok, evaluated at compile time
Statements¶
Assignment¶
Most assignment types are supported. Destructuring assignment is supported only for tuples, and the *
operator is not supported.
# Ok
a = 1
b += 2
c.x = 3
d[0] = 4
(e, f), g = (1, 2), 3
# Not ok
h, *i = 1, 2, 3 # Not supported
if a > 0:
pass
Conditional Statements¶
The standard conditional statements are supported.
if / elif / else¶
if a > 0:
...
elif a < 0:
...
else:
...
When the condition is a compile-time constant, the compiler will remove the unreachable branches:
v = None
if v is None:
v = Vec2(1, 2)
debug_log(v.x + v.y)
v = None
# The 'if' branch is always taken
v = Vec2(1, 2)
debug_log(v.x + v.y)
This is useful for handling optional arguments and supporting multiple argument types:
def f(a: Vec2 | None = None):
if a is None:
a = Vec2(1, 2)
debug_log(a.x + a.y)
def f(a: Vec2 | int):
if isinstance(a, Vec2):
debug_log(a.x + a.y)
else:
debug_log(a)
match / case¶
The match
statement is supported for matching values against patterns. All patterns, including subpatterns,
except mapping patterns and sequences with the *
operator are supported.
Records have a __match_args__
attribute defined automatically, so they can be used with positional subpatterns.
match x:
case 1:
...
case 2 | 3:
...
case Vec2() as v:
...
case (a, b):
...
case Num(a):
...
case _:
...
As with if
statements, the compiler will remove unreachable branches when the value is a compile-time constant:
v = 1
match v:
case Vec2(a, b):
debug_log(a + b)
case Num():
debug_log(v)
case _:
debug_log(-1)
v = 1
# 'case Num()' is always taken
debug_log(v)
Loops¶
while / else¶
While loops are fully supported, including the else
clause and the break
and continue
statements.
while a > 0:
if ...:
break
if ...:
continue
...
else:
...
for / else¶
For loops are supported, including the else
clause and the break
and continue
statements.
Custom iterators must subclass SonolusIterator.
for i in range(10):
if ...:
break
if ...:
continue
...
else:
...
Tuples can be iterated over and result in an unrolled loop. This can be useful for iterating of objects of different, types, but care should be taken since it results in more code being generated compared to a normal loop:
for i in (1, 2, 3):
debug_log(i)
debug_log(1)
debug_log(2)
debug_log(3)
Functions¶
Functions and lambdas are supported, including within other functions:
def f(a, b):
return a + b
def g(a):
return lambda b: f(a, b)
Function returns follow the same rules as variable access. If a function returns a non-num value, it most only
return that value. If the function always returns a num, it may have any number of returns. Similarly, if a function
always returns None (return None
or just return
), it may have any number of returns.
The following are allowed:
def f():
return Vec2(1, 2)
def g(x):
# Only one return is reachable since isinstance is evaluated at compile time
if isinstance(x, Vec2):
return Vec2(x.y, x.x)
else:
return x
def h(x):
# Both returns return the exact same value
x = Vec2(1, 2)
if random() < 0.5:
debug_log(123)
return x
else:
return x
def i(x):
# All return values are nums
if random() < 0.5:
return 1
return 2
The following are not allowed:
def j():
# Either return is reachable and return different values
if random() < 0.5:
return Vec2(1, 2)
return Vec2(3, 4)
def k():
# Both the return and an implicit 'return None' are reachable
if random() < 0.5:
return Vec2(1, 2)
Outside of functions returning None
or a num, most functions should have a single return
statement at the end.
Classes¶
Classes are supported at the module level. User defined classes should subclass Record
or have a supported
Sonolus.py decorator such as @level_memory
.
Methods may have the @staticmethod
, @classmethod
, or @property
decorators.
class MyRecord(Record):
x: int
y: int
def regular_method(self):
...
@staticmethod
def static_method():
...
@classmethod
def class_method(cls):
...
@property
def property(self):
...
Imports¶
Imports are supported at the module level, but not within functions.
assert¶
Assertions are supported. Assertion failures cannot be handled and will terminate the current callback when running in the Sonolus app. In debug mode, the game will also pause to indicate the error.
assert a > 0, 'a must be positive'
pass¶
The pass
statement is supported.