CHAPTER 7
Class represents the compound type.
In Julian, classes, as well as other derivative compound types, can only be defined at the start of a file, optionally after module and import declarations. Since Julian recommends OO-styled programming over loose scripting, files which contain class definitions are expected to not have script code outside the type definitions. If they do, however, the code outside of type definitions will not be executed if the file is loaded as part of import resolution.
An example of class definition:
class Machine {
private String name;
Machine(string name){
this.name = name;
}
void run(){
Console.println(name + " is running...");
}
}
As can be seen from above, class definition contains a couple of components that are usually expected in OO languages. These include:
Class must have a name starting with an uppercase character (A-Z). This rule applies to other compound types such as interface.
You may continue reading the rest of this chapter. But for quick references here is an index of topics that we are going to cover:
A class may contain static members. Static fields store values to be shared throughout the engine runtime.
class Machine {
static const String MAX = 3;
}
To access to static member, the class name must be specified. This is true even if the member is accessed from the same class.
class Settings {
static int volume;
static void set(int i){
Settings.volume = i;
}
}
void function(){
return Settings.volume;
}
Static methods are for defining shared logic in a way similar to global functions. Different from functions though, static methods do not have access to global variables. They also must be accessed through class member access syntax.
Settings.set(10);
A special static member is type initializer, which takes a similar form as a nullary (taking 0 parameters) constructor but uses static modifier.
class Settings {
static int volume;
static Settings(){
Settings.volume = 20 + GlobalSettings.volume;
}
}
The type initializer is called when the type is loaded into the engine. We will cover type loading towards the end of this chapter.
Static members of class are not particularly interesting as in the most cases they are used as a means to simulate name-scoped global scripting. More commonly, one wants to instantiate an instance of a class, which can have its exclusive storage and thus function as the basic unit, i.e. the object, in OO programming. To create a new instance, use new
expression.
Car car = new Car("Mercedes", 300);
The function-calling-like expression following new
operator is the constructor invocation. The arguments are provided as if a function were called. Since constructors can be overloaded, the scripting engine will try to find the first constructor, in the declared order, that matches the arguments passed in. One must note that this search is not for finding the most perfect match, but the first one that is compatible. For example,
class Machine { }
class DrillingMachine : Machine { }
class Factory {
Factory(Machine m) {
Console.println("created factory with Machine.");
}
Factory(DrillingMachine d) {
Console.println("created factory with DrillingMachine");
}
}
Factory f = new Factory(new DrillingMachine()); // "created factory with Machine."
If a class is defined without an explicit constructor, the engine will add a default constructor that takes no argument. However, if the class inherits from another which does contain non-nullary constructors, it's required that this class's constructor invoke any of the parent constructors using super()
call. We will talk more about inheritance in the coming sections.
The field members of a class can have an initializer. These initializers will be called ahead of constructor call during instantiation.
class Machine {
int size = 10;
string config;
Machine(string name){
config = name + "-" + size
}
}
Machine m = new Machine("tunneler");
Console.println(m.config); // tunneler-10
Since Julian is a purely interpreted language, visibility control through special modifiers makes little sense. However, due to the design goal we laid out in the first chapter, these features are implemented nonetheless. Julian provides 4 access modifiers, which cannot be used in mix.
If a member is to be accessed in violation of the visibility rules, a runtime exception is thrown.
There are some restriction over the usage of visibility modifiers. First, Julian doesn't allow reducing visibility along the inheritance chain (next section), top-down. The following code will cause class definition exception when the child class is loaded.
class Machine {
public int run(){}
}
class Engine : Machine {
protected int run(){}
}
Engine e = new Engine(); // Trigger class loading exception. The visibility on method fun() is reduced in child class.
Also, when overloading a method (more details coming in a later section in this chapter), all of the overloaded methods must have same visibility.
Julian's class system supports single-parent inheritance. A class, except Object, has exactly one parent class. It can have multiple interfaces though. We will cover interfaces in the next chapter.
To declare a class with a parent class, use ':' as the separator right after the class name during definition:
class Car : Machine {
}
If the parent class has defined any parameterless constructor, its subclass must implement at least one constructor to make an explicit call to the parent constructor.
class Car : Machine {
Car(string name) : super(name) {
}
}
A subclass inherits all the non-private fields and methods from its parent class. This means, from a storage perspective, a field that is allocated to a base class would be also allocated to a subclass. The calls may access to these inherited members as if they were originally defined by the subclass itself.
class Machine {
string name;
}
class Car : Machine {
}
Car c = new Car();
c.name = "Mitsubishi";
To access an inherited field from a subclass' method, no access qualifier is needed. On the other hand, to explicitly invoke a method defined by the parent class, it may be required that keyword super
be used. This is because the dispatching of method invocation in Julian is dynamic, and without specifying the scope it will always end up with the implementation at the end of inheritance chain.
class Animal {
int height;
void describe(){
Console.println("I am an animal...");
}
}
class Rabbit : Animal {
void describe(){
super.describe(); // Call Animal's describe(). Be careful, without using 'super' this will cause stack overflow.
Console.println("I am a Rabbit, with a height of " + height);
}
}
Of particular interest, a subclass has access to any protected members of its ancestor classes.
class Animal {
private int height;
protected int getHeight(){
return height;
}
}
class Rabbit : Animal {
void describe(){
Console.println("I am a Rabbit, with a height of " + getHeight());
}
}
Rabbit r = new Rabbit();
r.describe(); // OK
int h = r.getHeight(); // Runtime error
The most powerful feature of class inheritance, and OO programming in general, is the polymorphism enabled by method overriding.
In Julian, a non-private method can be overridden by a subclass, effectively redefining the runtime behavior of an instance regardless of its in-source typing.
class Animal {
void speak(){
Console.println("I am animal...");
}
}
class Rabbit : Animal {
void speak(){
Console.println("I am rabbit...");
}
}
Animal a = new Rabbit();
a.speak(); // "I am rabbit..."
In the code above, despite being declared as an Animal, we still get a rabbit talking. Admittedly, the polymorphism might be less appealing in an interpreted language, as at this point the type compatibility check becomes more of a burden to programmers than a tool. This feature, like visibility control, is implemented more as a means of helping developers to transit from compiled languages.
A class can be abstract. An abstract class may have, but not necessarily, abstract methods. An abstract class may have constructors, but it cannot be instantiated.
abstract class Animal {
abstract void run();
}
A non-abstract class that inherits from abstract class must implement those abstract methods. An abstract sub-class doesn't have to.
Normally, the abstract class is used to define common logic for multiple concrete classes, which would only need to provide the implementation for certain "hooks". It's also common that an abstract class doesn't contain any abstract method, but serves more like a mix-in base class so that its methods, typically decorated with protected
modifier, can be shared by the derivatives. In some cases the abstract class may also play the role of interface definition, so that it can be used as a type from the caller's perspective without actually locking down the concrete implementation. Such usage, however, is best left to interfaces, which we will cover soon in this tutorial.
A variable can have its type checked by is
operator, which takes a type's name as the second operand.
int i = 5;
string s = "abc";
bool f1 = i is int; // true
bool f2 = s is Object; // true - if the type is same to, or a subtype of, the given type parameter.
Explicit type casting can be done by (T)
operator, which takes a type's name as first operand. The usual rules for casting apply.
class Animal { }
class Rabbit : Animal { }
Rabbit r = new Rabbit();
Animal a = (Animal)r; // Upcasting is not needed.
Rabbit r2 = (Rabbit)a; // Downcasting is explicitly required, and will throw exception if the runtime type is incompatible.
A class can be declared as static. A static class can not be instantiated (either by new operator or through Reflection API), and can only contain static members.
static class GlobalSetting {
private static int volume = 100;
private static const int maxVolume = 200;
static int changeVolume(int diff) {
int nvolume = volume + diff;
if (nvolume > maxVolume) {
nvolume = maxVolume;
} else if (nvolume < 0) {
nvolume = 0;
}
volume = nvolume;
}
}
A static class can still have a "parent" class so that it can share some static code from another class. This has no implication to the actual inheritance. In the case of static class, they all technically inherit from Object, although this relationship is only implied and not very much meaningful.
Static class is fundamental to the extension feature, which will be covered in a later chapter.
Another main feature of modern OO language is to support method overloading, namely same method name with different signature. This is supported in Julian too, although the runtime behavior may be less reliable due to its interpreted nature.
class Factory {
void add(BoringMachine c){ }
void add(Machine m){ }
void add(Machine m, int count){
for(int i = 0; i < count; i++) {
add(m); // Not a deep copy, but this is irrelevant to our example.
}
}
void add(DrillingMachine c){ }
}
Factory f = new Factory();
BoringMachine bm = new BoringMachine();
f.add(bm); // call add(BoringMachine)
DrillingMachine dm = new DrillingMachine();
f.add(dm); // call add(Machine), NOT add(DrillingMachine)
f.add(dm, 3); // call add(Machine, int)
Of particular importance, the dynamic dispatching doesn't try to find the perfect matching. Instead, it's only trying to find the first one that is compatible. The compatibility is determined by a combination of three factors:
This is why, in our example above, the method add(DrillingMachine c)
never gets called. Also note that, like other languages, the return type is not part of signature and thus not considered during method dispatching.
We have so far covered the basic usage of classes, and it's time to go over a little about how they get loaded into the system.
A class is getting loaded the first time it's actually used in the logic. The most common first usage is by a variable declaration.
class Animal { }
Animal anim; // Class Animal will be loaded here.
There are other cases where a type is considered to be used for the first time, but the gist here is that the loading is triggered by running logic, not type declaration.
class Cat : Animal {
static Cat(){
Console.println("Cat is loaded.");
}
}
// Just declaring a type will not force its loading.
Running the code above, you will not see "Cat is loaded". Neither Cat nor Animal will be loaded into the engine since they are never used.
In fact, the statement that type declaration doesn't trigger type loading is an over-simplification. When parsing and processing type definitions, the engine will try to detect the types being referenced by the type being declared, and perform a lot of checks to ensure the legality of current definition. A check error may be raised at this point (say, Animal is not found), but such errors will only surface by the time we use the declared type.
When a type is to be loaded, the following operations happen in order:
To show an interesting case of loading order, consider this example, in which A and B are to be loaded together since they form a closure due to cross-references.
class A {
static int v = B.b;
static int a = 10;
}
class B {
static int v = A.a;
static int b = 20;
}
int x = A.v + B.v; // Might be 10 or 20, but definitely not 30.
If at least one type in the closure encountered an error during loading, none of these types will get loaded. As one of Julian's design doctrines, this is called Loading Completeness Principle (LCP).