Classes and instances¶
Coding with class¶
Sometimes, you need to define an aggregate structure because you need to treat a bundle of several values as a single unit. A very simple example would a two-dimensional point,
which has x and y coordinates. One way to do this would be to use a Table with strings as keys and numbers as values:
var pt = {"x": 5, "y": 10}
print("x =", pt["x"])
print("y =", pt["y"])
If you use a lot of points, you could create a factory function that creates new points:
function create_point(x as Number, y as Number)
return {"x": x, "y": y}
end
var pt = create_point(5, 10) # same as above
This is a perfectly valid thing to do, and Table is a very flexible and useful data structure which can be used in many ways. Nevertheless, this approach has a few limitations.
First, it is relatively cumbersome: some languages do provide some syntactic sugar for tables to make this type of use case easier, but because tables in Phonometrica are based
on the JSON specification, this is not possible here. More importantly, this approach is not particularly robust because (a) nothing prevents you from adding or removing values
from the table after it has been created and (b) there is no way for a function to know that it should expect a point, with x and y coordinates, and nothing else. Suppose we have the following code:
function create_point(x as Number, y as Number)
return {"x": x, "y": y}
end
function print_point(p as Table)
print("({p["x"]}, {p["y"]})")
end
var pt = create_point(5, 10) # same as above
... # A lot of code
remove(pt, "x") # a small mistake somewhere
print_point(pt) # boom! "x" no longer exists
While this example may look contrived, it is easy to make this kind of mistake in relatively large codebases, and it requires a lot of discipline to avoid them.
Fortunately, there is an alternative: like many modern programming languages, Phonometrica’s scripting language lets you define classes to create new types.
Overview of classes¶
Phonometrica’s scripting language is an object-oriented programming language. This means that all values, including primitive values such as integers and Booleans,
are objects on which we can perform computations using functions. Every type in Phonometrica is represented by a class, which is kind of “blueprint” that allows
us to create instances of the type it defines. For example, the following code creates instances of the classes Integer and String, with values 5 and hello, respectively.
var i = 5
var s = "hello"
You can test which class a value belongs to with the is operator, which also takes inheritance into account:
print(5 is Integer) # prints true
print(5 is Number) # prints true, because Integer inherits from Number
print(5 is String) # prints false
Each class may define a number of fields (also called attributes or properties) which can be read and sometimes written to. The fields of an instance
are accessed with the dot operator ., as we will see below.
In addition to using built-in classes, Phonometrica allows you to define your own classes with the keyword class. Here is a minimal example:
class Point
end
Executing this will create an (empty) class named Point, which can now be used to create instances of that class. (Note that by convention, the name of a class starts with an upper-case letter.)
A class declared at the top level of a script is public, which means that it is visible to scripts that import yours; just like functions, you can declare a private class by adding
local before class, in which case the class will only be visible in the current script:
local class Point
end
Since this class is empty, it is not really useful. We can add fields with the keyword field, one per line:
class Point
field x
field y
end
This class now has two fields, named x and y. Since we haven’t assigned them any value, their value will default to null, just like regular variables.
To ensure that any newly created point has sensible values for its fields, we can assign them a default value when we declare them:
class Point
field x = 0
field y = 0
end
Assigning a default value to each field is recommended since it ensures that every newly created instance is in a valid state. (You can also give a field an explicit
type with as, e.g. field x as Number = 0.) To create an instance,
we need to call the class’ constructor, which is responsible for creating the instance of the class and initializing it. To do this, we call the name of the class
as if it was a function:
class Point
field x = 0
field y = 0
end
var p = Point()
print(p) # prints "<Point>"
print(p.x) # prints 0
In the above code, the call to Point() will return a new instance of the class Point, and we can now access its fields using the dot operator. By default, fields
can be read and written to, so we could modify the values of our point:
p.x = 5 # x now has the value 5
p.y = 10 # y now has the value 10
Note that the fields are specific to the instance of the class: this means that modifying the value of an instance’s field only changes the value for that instance (p in this case).
Methods and initializers¶
Default initialization of fields is convenient, but sometimes we might want to be able to initialize our new instance with custom values. One possibility would of course be to create a function outside of the class and call it to initialize the instance, and we could perform some checks to ensure that the values are valid. For example, if we want to ensure that points have non-negative coordinates, we could do something like that:
class Point
field x = 0
field y = 0
end
function create_point(x as Number, y as Number)
assert(x >= 0 and y >= 0, "x and y must be non-negative")
var p = Point()
p.x = x
p.y = y
return p
end
var p = create_point(10, 30)
While this would work, it is a bit cumbersome and, perhaps more importantly, it decouples the initialization step from the class. Fortunately,
there is a better approach: Phonometrica lets you define a special kind of functions inside the class: such functions are called methods, and are created with the keyword method.
Methods are special in that they always have an implicit argument, named this, which represents the instance of the class, and Phonometrica only recognizes
a small number of methods which relate to the internal state and representation of a type.
The most important method is called init and, as its name suggests, it is responsible for initializing an instance. If a class defines no init method,
Phonometrica provides a default constructor which takes no argument and sets every field to its default value, as in the example above. But we could define our
own initializer:
class Point
field x = 0
field y = 0
method init(x as Number, y as Number)
assert(x >= 0 and y >= 0, "x and y must be non-negative")
this.x = x
this.y = y
end
end
var p = Point(10, 30)
This code produces the same result as the previous code snippet, but it is more concise and the initialization code is now part of the class, which makes it easier to reason about.
Notice that we do not need to (and in fact, we can’t) declare the this variable, which represents the instance of the Point class being initialized. Also note that we
can’t return a value from an initializer, since this value would be ignored anyway.
Be aware that as soon as you define your own initializer, the automatic default constructor is no longer available: with the class above, calling Point()
with no argument is now a dispatch error, because the only init method takes two numbers. Like functions, methods can be overloaded, so if you still want to
be able to create a default-initialized point, simply provide a no-argument init overload as well:
class Point
field x = 0
field y = 0
method init()
print("Calling default initializer")
end
method init(x as Number, y as Number)
assert(x >= 0 and y >= 0, "x and y must be non-negative")
this.x = x
this.y = y
end
end
var p = Point() # prints "Calling default initializer"
Whether you create your own initializer(s) or not, Phonometrica will always pre-initialize the fields of an instance with the value they were assigned when they were declared, or null if they were not assigned any value,
before calling any initializer. In the example above, this means that x and y are already set to 0 when we enter init().
class Point
field x = 0
field y = 0
method init()
print("{this.x},{this.y}") # prints "0,0"
end
method init(x as Number, y as Number)
print("{this.x},{this.y}") # prints "0,0"
assert(x >= 0 and y >= 0, "x and y must be non-negative")
this.x = x
this.y = y
end
end
String representation of classes¶
Another useful method is to_string(), which takes no argument and must return a string representation of the instance of the class it is attached to. This method
will be called automatically wherever a string representation is expected, for instance by print, by string interpolation, or by the concatenation operator &.
By default, printing an instance of our Point class produces the rather terse <Point>. This is better than generating an error but not particularly useful. We could extend our class with a to_string() method:
class Point
field x = 0
field y = 0
method init(x as Number, y as Number)
assert(x >= 0 and y >= 0, "x and y must be non-negative")
this.x = x
this.y = y
end
method to_string()
return "Point(" & this.x & "," & this.y & ")"
end
end
This will now provide a much more informative representation of the point:
var p = Point(10, 30)
print(p) # prints "Point(10,30)"
Value vs reference types¶
By default, user-defined types have value semantics: copying an instance behaves like copying its content, so mutations made through one variable are never visible through another. Consider the following code:
class Point
field x = 0
field y = 0
method init(x as Number, y as Number)
this.x = x
this.y = y
end
end
var p1 = Point(10, 5)
var p3 = p1
p1.x = 100
assert(p3.x == 10)
If we create a copy of a point (p3) and later modify the original instance, as we did with p1 here, the copy is unaffected and preserves the values it had when it was assigned.
(Behind the scenes, Phonometrica doesn’t actually copy anything until one of the two variables is modified, so this is cheap.)
Note that equality between class instances is based on identity, not on content: two independently constructed instances are never equal, even if all their fields hold the same values.
var p1 = Point(10, 5)
var p2 = Point(10, 5)
assert(p1 != p2) # different instances, even though the coordinates are equal
If equality-by-content makes sense for your class, define an ordinary function that compares the relevant fields, for instance function same_location(a as Point, b as Point).
Value semantics is the default behaviour because it makes code safer and easier to reason about, since mutations are local to the variable that is being modified.
This behaviour works for many types, such as our Point example. Sometimes, however, you do want reference semantics, because each instance should be considered unique in some way, and several
variables should be able to observe mutations of the same underlying object.
Suppose you are implementing a graph: you would probably want each node and edge to be unique, so that for example several edges can reference the same node.
Phonometrica lets you create reference types by adding the keyword ref before class when you declare your type. Here’s a (minimalistic) example for nodes and edges with reference semantics:
ref class Node
field label = ""
field edges = []
method init(label as String)
this.label = label
end
end
ref class Edge
field source
field target
method init(source as Node, target as Node)
this.source = source
this.target = target
end
end
With a ref class, assignment shares the instance instead of copying it:
var n1 = Node("a")
var n2 = n1 # n1 and n2 refer to the same node
n1.label = "b"
print(n2.label) # prints "b"
When trying to decide whether you should use a value type or a reference type, remember that a value type holds its own data, so copying it makes a new independent copy, whereas a reference type points to shared data, so changes affect all references. Phonometrica optimizes value copying, so don’t choose a reference to make things faster: in general, use a reference type if you need shared data, otherwise use a value type.
Inheritance¶
A class can inherit from another class with the keyword is. The subclass has all the fields of its base class, plus the fields it declares itself,
and an instance of the subclass can be passed wherever the base class is expected:
class Point2D
field x = 0
field y = 0
end
class Point3D is Point2D
field z = 0
end
var p = Point3D()
print(p.x, p.z) # prints 0 0
print(p is Point2D) # prints true
Phonometrica supports single inheritance: a class has at most one base class.
Using classes as function parameters¶
In the example we took at the beginning of this tutorial, our point object was represented with a table. Suppose that we want to define a function that reinitializes a point. If we use a table,
we have no way to distinguish tables that store a point from other tables, so we might need to be particularly careful when we pass table-as-a-point to a function. However, once we have defined
a new Point class, we can use it as a parameter for a function:
function reinitialize(ref p as Point)
p.x = 0
p.y = 0
end
We can now be sure that only points will be passed to this function, so that it’s guaranteed to have all the attributes of a Point. (Note that unlike a parameter
declared as Object, a parameter declared with a specific class does not accept null.)
Methods and functions¶
Methods and functions are used encode behaviour, so you may wonder why both exist and when to choose one over the other. Programming languages differ with respect to the amount of “object-orientedness” they allow: some languages like C and early versions of Pascal do not have methods at all, whereas others such as Java do not have functions defined outside of classes. Others, such as Python, allow both.
Phonometrica differs from the majority of object-oriented programming languages in that it is based on multiple dispatch (see Functions). This means that it allows several versions of a function to coexist as long as they have a different signature, and it will choose the correct function based on the number and type of arguments passed to the function. Because of this design choice, most behaviour should (and in fact, must) be encoded using functions, which are always defined outside of a class. Methods are reserved for very specific behaviour associated with the internal state or representation of an object. As a result, there is a fixed and very limited set of methods that can be defined for a class. Currently, these are:
init, to initialize a new instance;to_string, to provide a meaningful string representation of the instance;get_itemandset_item, to make instances indexable with the bracket operator (x[i]reads viaget_item,x[i] = vwrites viaset_item);iterateandnext, to make instances iterable withfor ... inloops.
If you need to implement any of these behaviours, use methods defined inside the class. For everything else, use functions defined outside of the class, taking the instance as their first parameter.