Skip to content
Cameron Purdy edited this page Dec 8, 2023 · 31 revisions

Understanding Classes

We've already seen a few classes; for example, the HelloWorld module is a class. The first thing to understand about classes is this: Classes exist to help organize things, just like files and directories help to organize a hard drive. So, before we even attempt to define what a class is, it would be helpful to imagine classes in your mind in much the same way you imagine files and directories on your hard drive. Sometimes, the hard drive organization makes sense: Good file names, obvious file types, and easily examinable file contents. Of course, for most people, there are big parts of the hard drive that look like a huge and unintelligible mess!

Nonetheless, all of your information is stored in files, and those files are organized into directories -- because that is how operating systems store things. Similarly, in an class-based Object Oriented language like Ecstasy, everything is part of a class because that is how a class-based Object Oriented language organizes things. Classes are not mystical magical beings; they exist first and foremost as simple organizational tools.

Continuing the analogy, a module is like the root directory on a hard drive, and a package is like a sub-directory. Both modules and packages are classes, but they also provide the hierarchical organization that is similar to the directory structure on a hard drive. In addition to module and package, there are six other keywords that Ecstasy uses to define classes:

  • interface - a form of an abstract class that describes a set of properties and methods; as such, it is a reusable building block for building other classes. The term "interface" here has much the same meaning as when it is used in the term application programming interface (API). The use of this keyword in Ecstasy is consistent with its use in Java and C#.
  • mixin - a class that can be mixed into another class; a class can incorporate a mixin, or can be annotated by the mixin. A mixin is a re-usable building block that can be added to other classes -- even classes that weren't designed to have such functionality added to them.
  • const - a specialized form of a class that is immutable by the time it completes construction; const classes are value types.
  • enum - a const class that defines a list of enumerated values. The use of this keyword in Ecstasy is consistent with its use in Java and C#.
  • service - a class that defines a (i) new domain of mutability, which is (ii) asynchronous, (iii) concurrent, and (iv) has an explicitly manageable lifecycle. While services in Ecstasy are often used to replace threads in lower level languages like Java or C++, the closest analog for a service is a Web Worker in JavaScript, or a Process in Erlang.
  • class - the catch-all used to define any class that isn't covered by the previous keywords. The use of this keyword in Ecstasy is consistent with its use in C++, Java, and C#.

Objects versus Classes

Classes are the blueprints for objects.

If an object exists, it exists because there was a blueprint that was used to create it. As in Java and C#, if an object of some class Person exists, then it probably exists because some code that contains the expression "new Person()" was executed. There are other ways to create objects, but for now, let's keep it simple, and assume that they were all created using new.

A blueprint for an object is called the object's class. And new-ing an object is called instantiation, as in:

  • "... the object was instantiated from the class"
  • "instantiating the object ..."
  • "... a new instance of the class"
  • "creating an instance of the class ..."

A concrete class is a set of blueprints that is complete enough to be used to instantiate an object. An abstract class is a set of blueprints that lacks sufficient information on its own to instantiate an object. But don't think of an abstract class as a failure, like not finishing your homework! Instead, think about an abstract class as some useful piece of design that you can use in a more complete design; in other words, abstract classes are reusable building blocks.

There are a few different reasons why a class may be abstract:

  • A class that is declared with the interface keyword is always abstract.
  • The class representing an entire enumeration, such as Boolean, is always abstract. On the other hand, the actual values of the enumeration, like True and False for the Boolean enumeration, are concrete classes; specifically, enumeration values are singleton const classes, each of which is a sub-class of the enumeration class.
  • A mixin is abstract, because it needs to be mixed into another class in order to form an instantiable class.
  • A class can be annotated with the @Abstract annotation in order to make it explicitly abstract.
  • Instead of producing a compilation error, the compiler may mark a class as abstract if the class is missing some necessary design information, but no code is being compiled that actually attempts to instantiate it.

There are two truisms related to objects and classes:

  • All objects are instantiated from concrete classes; if an object exists, it is an instance of a concrete class.
  • Everything is an object; even a class is an object. But this is simpler than it sounds: In Ecstasy, anything that exists and you can do something with is an object. And since classes exist, and since you can do something with them, they must be objects.

And finally, there are only a few ways that classes themselves are used:

  • Classes are used to instantiate objects, such as in the code: return new User(id);.
  • Classes are used for organization. That means that the name of a class can be used to tell the compiler where to look for another name. For example, the compiler understands that FPNumber.PI refers to the named constant PI that is defined within the class FPNumber.
  • Classes are actual objects, such as the name Boolean in the code: Class boolclass = Boolean;.
  • A singleton class makes its singleton instance available via the name of the class. Here are two obvious examples that we have already discussed:
    • A module class: Module m = HelloWorld; (the name "HelloWorld" identifies the singleton instance of the module)
    • The values of an enumeration: Boolean value = True; (the name "True" identifies the singleton instance of the enum value)
  • The name of a class can be used to indicate the corresponding type. In the example immediately above, Boolean value = True;, the class name Boolean is used to specify the type of the variable. We'll cover the concept of types in the next chapter.

Composing Classes: How to build a blueprint

This guide is meant as an introduction to the Ecstasy language, and not a definitive syntax reference. In other words, we want to convey 90% of the useful information that a definitive syntax reference would give you, while somehow using 90% fewer words and unveiling 90% less complexity. To help us get there, we've already explained that many of the syntax choices in Ecstasy were purposefully based on Java and C#; we hope that developers can rely on their existing Java and C# knowledge to assume the correct Ecstasy syntax, and most of the time the assumptions should work. But of course, there are some syntactic differences.

So how is a class defined?

  • The class' form includes the keyword (class, const, enum, etc.) used to define it, as well as the optional -- and rarely-used -- modifiers public, private, protected, and static.
  • The class' name is a simple (one part) name; the exception to this rule is the name for a module, which may be qualified like an Internet domain name, such as MyApp.mycompany.com.
  • The class' building blocks may include annotations, a super class, a list of implemented and/or delegated interfaces, and incorporated mixins.
  • The class' contents may include: type parameters, constructors, methods, functions, properties, constants, typedefs, and classes.

Let's take a look at how building blocks are used. We'll start with the well-known class, HashMap, from the Ecstasy core library:

class HashMap<Key extends Hashable, Value>
        extends HasherMap<Key, Value>
        implements Replicable
        incorporates CopyableMap.ReplicableCopier<Key, Value>

Everything here would seem quite at home in either Java (HashMap<K,V>) or C# (Dictionary<TKey,TValue>), except for the incorporates clause. While designing mixins is not a beginner-level topic, you can think of the incorporates clause as being similar to the extends clause in some ways: extends specifies a named super-class that this class inherits from, while incorporates specifies a mixin that has functionality that we want to assimilate in a Borg-like manner.

Stylistically, CamelCase is used for class and type names, and type parameters use natural names like "Key", instead of a first letter thereof, and they do not have an extra "T" glued to the start of their name. There's a very important point hidden here, so let's make it obvious: There is no conceptual difference in Ecstasy between the type of Key and the type of Int. That's why they are written out in the same manner, capitalized in the same manner, and so on. They are both "named types", and in Ecstasy it would be a bit strange and confusing to arbitrarily assign some weird naming convention for either one of them.

Interfaces are defined similarly to Java's syntax, including the use of the extends keyword instead of implements (or C#'s :) when listing super-interfaces. Here is the well-known List interface, for example:

interface List<Element>
        extends Collection<Element>
        extends UniformIndexed<Int, Element>
        extends Sliceable<Int>

In addition to the items already covered, one thing immediately stands out compared to Java and C#: In Ecstasy, Int is a class, and as such, it is capitalized with CamelCase just like every other type and class name. Ecstasy does not have an "int" type, or any other "primitive" types; all Ecstasy values are objects, including things as simple as bits, bytes, booleans, characters, and integers. And as we discussed earlier, those objects must all have classes from which they are instantiated. And since they're just normal classes, the names are written out in the same manner as other classes, capitalized in the same manner, and so on.

Moving on to mixins, here is an example declaration of a mixin, ListFreezer, whose job it is to add the ability to freeze (i.e. "make something immutable") to any List:

mixin ListFreezer<Element extends Shareable>
        into List<Element> + CopyableCollection<Element>
        implements Freezable

A small clarification is needed: This mixin does not actually apply to just any List. Specifically, it only applies to a List class that has an Element type that is Shareable, and the into clause specifies that the mixin can only be applied to a List class that is also a CopyableCollection. This is how a mixin defines its constraints on what classes it can be incorporated into. As you can see, though, the mixin is itself a form of class, specifying that it implements the Freezable interface, for example. So when the mixin is incorporated into a List, the resulting List class will automatically pick up those additional building blocks, such as implementing the Freezable interface. For now, don't focus on how this happens; we'll discuss those details in a subsequent chapter.

Here's another mixin, ListMapIndex, which is used to add a fast-lookup index to a ListMap. Look at its into clause and its type parameter declaration: It specifies that it can only be mixed into a ListMap, and more specifically, into one whose Key type is Hashable:

mixin ListMapIndex<Key extends Hashable, Value>
        into ListMap<Key, Value>

Let's look at where it is used, by the class ListMap:

class ListMap<Key, Value>
        implements Map<Key, Value>
        implements Replicable
        incorporates CopyableMap.ReplicableCopier<Key, Value>
        incorporates conditional ListMapIndex<Key extends immutable Hashable, Value>
        incorporates conditional MapFreezer<Key extends immutable Object, Value extends Shareable>

This is, by far, the most complicated example that we have examined. Note that ListMap does not require that its Key be Hashable, but yet it incorporates ListMapIndex, which as we just noted requires its Key to be Hashable! So let's examine that incorporates line closely:

incorporates conditional ListMapIndex<Key extends immutable Hashable, Value>

Note the addition of the conditional keyword: ListMap is defining a blueprint that says: "If my Key type is an immutable Hashable type, then (and only then!) automatically incorporate the ListMapIndex mixin into my class." Pause for a moment to think about what is happening here: It is as if the class is being automatically altered based on the type of Key that the ListMap is instantiated with!

There is one more way to specify a building block, which is the delegates clause, seen here in the const ChickenOrEggMapping class, shown here in full:

const ChickenOrEggMapping<Serializable>(function Mapping<Serializable>() egg)
        delegates Mapping<Serializable>(chicken) {
    private function Mapping<Serializable>() egg;

    private @Lazy Mapping<Serializable> chicken.calc() {
        return egg();
    }
}

The "egg" function is provided as part of the constructor, and then the "chicken" is lazily created the first time that it is needed -- by calling the "egg" function and caching the resulting "chicken". What the delegates clause does, though, is incredible: It will then automatically redirects all of the calls coming into the ChickenOrEggMapping, and it will send them to the newly hatched chicken. In other words, the delegates clause implements an entire interface by routing all of the interface methods to another object -- and it does all that in one simple and obvious line of code. By delegating all of the Mapping calls to the chicken, the ChickenOrEggMapping acts just like a chicken, even though it was constructed with only an egg. And by delegating to a @Lazy property, the first such delegating call to come in, will automatically cause the chicken to be hatched from that egg!

Enumerations

Enumerations in Ecstasy are fairly straight-forward. The syntax is largely based on Java's enum syntax, and is easy to read for common use cases; for example:

enum Event {Created, Modified, Deleted}

In this example, the Event class is compiled as an @Abstract const class, and the class object for the enumeration implements the Enumeration interface, allowing easy programmatic access to the values of the enumeration, such as iterating through all of the enumeration's values:

for (Event event : Event.values) {
    console.print($"event={event}");
}

Each enumeration value is a singleton const class that extends the enumeration class and implements the Enum interface; in the example, Created is a singleton const that extends Event. This allows for simple and obvious usage of enumerations, such as:

Event event = Created;

And:

if (event != Deleted) {
    process(event);
}

Class Contents

As stated above, the contents of a class are: type parameters, constructors, methods, functions, properties, constants, typedefs, and classes. Let's split these into two groups:

class-level
(static, no this required)
object instance-specific
(this required)
type parameters
constructors
functions methods
constants properties
typedefs* typedefs
static child classes virtual child classes

There's a lot to explain here, and there's no simple way to explain it. Let's start by illustrating the "no this required" column, which is the simple part of this topic:

class Example {
    static Int addOne(Int n) {
        return n+One;
    }
        
    static Int One = 1;
    
    typedef String as Text;
    
    static const Point(Int x, Int y);
}

Notice that each one of these items is unrelated to its containing class, other than happening to exist syntactically within it:

  • The function addOne operates only on the value passed to it, and it can be called without having an instance of Example. And yes, the name Example.addOne references an actual function object (of type Function<<Int>, <Int>>).
  • The constant Example.One has a value unrelated to any instance of Example.
  • The typedef Example.Text is always a String, and is not based on or related to any instance of the Example class.
  • The const class Example.Point can be constructed without having an instance of Example, and it does not capture (i.e. it does not have) a reference to any instance of the Example class.

Each of these items can be referenced directly without having an instance of Example. This is an Example -- no pun intended! -- of a class being used as a means of organization ("somewhere to put stuff"), and is not some highfalutin Object Oriented concept. In some ways, Example is being used just like a package is often used: It is an organizational container of other stuff. As we explained in a previous section, classes within a package can be pasted inside the body of the package code itself, just like the Point above is found inside of Example. In fact, it would be perfectly legal in Ecstasy to simply change class to package in the Example code above!

You can see from this Example that you can place these kinds of items within a class, and access them either from code within the class, or by specifying the class name. But these items are not part of the object instances of the class. So with this simple part of the topic out of the way, let's move on to the more interesting topic of the things that are related to actual instances of the class.

Instance-Specific Class Contents

What we mean by instance-specific is simply that these contents can vary from instance to instance of a class, and that an instance of the class is required in order to access these contents. Let's start with a simple example:

const Point(Int x, Int y);

Breaking this down:

  • The form of the class is a const, which defines a class whose objects are immutable by the time that they complete construction.

  • The name of the class is Point, likely referring to a Cartesian coordinate system.

  • No explicit building blocks are used to define the class. However, all classes automatically implement Object, and all const classes automatically implement the Const interface, which is itself composed of Orderable, Hashable, Comparable, and Stringable.

  • The declaration ends with (Int x, Int y), which is short-hand notation for declaring two properties and defining an initializing constructor; the long-hand equivalent may be more familiar:

    const Point {
        Int x;
        Int y;
    
        construct(Int x, Int y) {
            this.x = x;
            this.y = y;
        }
    }
    

We'll cover properties in greater detail in a later chapter, but the important thing to keep in mind at this point is that each property on an object is itself an object that holds (or at least provides) a value; it is not a field. Let's add another property that measures the area of the region from the origin (0, 0) to the point:

const Point(Int x, Int y) {
    Int areaFromOrigin {
        @Override
        Int get() {
            return x.abs() * y.abs();
        }
    }
}

This new property whose type is Int and whose name is areaFromOrigin looks a lot like a class inside of the Point class -- and it is! Every read-only property automatically extends a class that implements the Ref interface, and every read/write property automatically extends a class that implements the Var interface, which itself extends the Ref interface. While the class that implements read-only and read/write properties is unknowable, it must exist -- because properties exist, and they are objects, and objects come from classes. As with the Module implementation code in the previous chapter, the linker is responsible for automatically providing an implementation of the Ref and Var interfaces for us.

The above example code is a bit too wordy, though. Fortunately, there is a short-hand syntax for defining a single method (in this case, get) on a property:

const Point(Int x, Int y) {
    Int areaFromOrigin.get() = x.abs() * y.abs();
}

For purposes of making an example, and without involving any cats or dogs (which seems to be a requirement for books focused on Object Oriented programming), let's introduce a sub-class that works in three-dimensional space. Before discussing the example, we do need to apologize for abusing sub-classing in this manner; if we didn't need an easy-to-explain example, we would never have done this:

const Point3D(Int x, Int y, Int z)
        extends Point(x, y) {
    @Override
    Int areaFromOrigin.get() {
        // technically this is volume, not area
        return (x * y * z).abs();
    }
}

There are a few important reasons for showing this example at this point:

  • To illustrate the use of the @Override annotation, which is required when overriding an instance member (e.g. method or property) of a class;

  • To show how one constructor can delegate to another using the short-hand notation; and

  • To have an excuse to show the long-hand form of the constructor:

    construct(Int x, Int y, Int z) {
        this.z = z;
        construct Point(x, y);
    }
    

There are various rules about what you can and cannot do from a constructor, and these rules are stricter in Ecstasy than in most languages. In an Ecstasy constructor, the object being instantiated does not yet exist; the this that the constructor has access to is not a Point object, but is a structure that will be used to instantiate the Point object after the constructors have all finished their execution. The structure contains only the fields (the raw memory storage) for the Point, so the code this.z = z in the constructor is not setting a property, but is simply storing the value in the structure's field in memory. Similarly, the constructor cannot call any of Point's methods on this, because the Point object doesn't yet exist during the constructor. We'll get into more details about constructors later on, but if you're playing around with code examples, these details will hopefully save you some frustration.

Methods in Ecstasy follow a syntax that is based closely on Java and C#. Continuing the Point example from above, let's add two methods:

void print() {
    @Inject Console console;
    console.print($"point={this}");
}

Point double() = new Point(x*2, y*2);

If you're paying attention, we have now created a huge mess, because Point3D will return a Point from the double() method. Or does it? There's a subtle detail here that needs to be explained: When the class Point has a method that says it takes or returns a Point, the compiler assumes that the method does not actually take or return an object of the exact class Point, but rather the method takes or returns an object of the current class. So on Point3D, we need simply to add this implementation:

@Override
Point3D double() = new Point3D(x*2, y*2, z*2);

This behavior is called auto-narrowing: The Point type when used in a declaration on the Point class will be assumed to automatically narrow on sub-classes. If that assumed behavior is wrong, then the syntax to tell the compiler that we meant the exact Point class (and not the auto-narrowing class) is to use the type Point!, as in, "I really mean Point!" And remember that the @Override annotation on the method is required here; to avoid common programming errors, overriding a method in Ecstasy is a conscious and explicit decision. We'll cover these topics in more depth when we get to the virtual behavior and the virtual child class topics.

Type Parameters

We've already seen some examples of type parameters, and even how they can affect the shape of the resulting class due to conditional incorporation. Type parameters in Ecstasy are similar to type parameters in C# and template parameters in C++, and the syntax is also very similar to type parameters in Java, although Java type parameters are erased (the type information only exists at compile time). The List and Map examples earlier in this chapter provide a sufficient overview of the similarities with other languages, so let's just look at some specific design considerations with Ecstasy type parameters:

  • Each type parameter automatically creates a property of the same name. For example, class Bag<Element extends Replicable> means that Bag will have an Element property of type Type<Replicable> that will contain the actual type provided to the type parameter.
  • Type parameters can be used both as "left hand side" and "right hand side" types. For example, continuing with the class Bag<Element extends Replicable> example above, it would be legal to write: Element e = new Element();, even though the actual type of Element is not known by the compiler. (The terms "left hand side" and "right hand side" do not have a single definition, but they are often used to specify the use of a type or a variable either on the left hand or the right hand side of an assignment operation; the concepts are also related to the terms "l-value" and "r-value", referring to something that can be assigned to, and something that can be assigned from, respectively.)
  • Type parameters make new classes. As already explained, List<Element> is a class, and while List<String> is still a List class, it actually defines a different class than List<Element>. The term "specializes" is sometimes used in languages with type parameters when the compiler automatically cut-and-pastes all of the List code for you for every single different Element type; for example, "List<String> is a specialization of the List class." But that is not what we mean; it's not that the class is cut-and-pasted; it's just that List<String> is a separate, different, unique class.
  • Type parameters are shared by a class with its virtual child classes. Consider the interface Map<Key, Value> and its virtual child interface Entry: The Entry interface does not have its own Key and Value type parameters, because its class exists contextually within a Map class. This is a subtle but important difference: There is a class Map<Int, String>.Entry, but there is no such class as Map.Entry<Int, String>.

Virtual Child Classes

We just introduced the concept of a virtual child class with the simple and well-known Map.Entry example, but the capability goes much deeper than the sharing of type parameters. While we won't go into a great amount of technical details at this point, virtual (i.e. non-static) child classes are virtual in much the same manner that virtual methods are virtual.

Instead of going into depth on this topic here, we'll just give one example to whet your appetite:

module VirtualChildExample {
    @Inject Console console;

    void run() {
        new SuperClass().doSomething();
        new SubClass().doSomething();
    }

    class SuperClass {
        void doSomething() {
            new Child().printSomething();
        }

        class Child(String name="superclass") {
            void printSomething() {
                console.print($"{name} child");
            }
        }
    }

    class SubClass
            extends SuperClass {
        @Override
        class Child(String name="subclass");
    }
}

Look carefully at the code: Nowhere does it ever say new SubClass.Child(). Yet when it's run, the output indicates otherwise:

superclass child
subclass child

The line of code that says "new Child()" instantiates the child class, virtually.

At this point, we've only covered a small fraction of the topic of how classes are defined, but it should be enough to provide context for the subsequent chapters. Now we're ready to introduce the Ecstasy type system.

Prev: Your first "Hello World!" Next: Understanding types