Introduction to Ceylon Part 2

Posted by    |      

This is the second installment in a series of articles introducing the Ceylon language. Note that some features of the language may change before the final release.

Creating your own classes

Ceylon is an object oriented language, so we usually write most of our code in classes. A class is a type that packages:

  • operations — called methods,
  • state — held by attributes,
  • logic to initialize the state when the object is first created, and,
  • sometimes, other nested types.

Types (interfaces, classes, and type parameters) have names that begin with uppercase letters. Members (methods and attributes) and locals have names that begin with lowercase letters. This is the rule you're used to from Java. Unlike Java, the Ceylon compiler enforces these rules. If you try to write class hello or String Name, you'll get a compilation error.

Our first version of the Hello class has a single attribute and a single method:

doc "A personalized greeting" 
class Hello(String? name) {
	
    doc "The greeting" 
    shared String greeting; 
    if (exists name) {
        greeting = "Hello, " name "!";
    } 
    else {
        greeting = "Hello, World!";
    }
    
    doc "Print the greeting" 
    shared void say(OutputStream stream) {
        stream.writeLine(greeting);
    }
    
}

To understand this code completely, we're going to need to first explore Ceylon's approach to program element accessibility — the shared annotation above, then meet the concept of an attribute, and finally discuss how object initialization works in Ceylon.

Hiding implementation details

In Java and C#, a class controls the accessibility of its members using visibility modifier annotations, allowing the class to hide its internal implementation from other code. The visibility modifiers select between pre-defined, definite visibility levels like public, protected, package private, and private. Ceylon provides just one annotation for access control. The key difference is that Ceylon's shared annotation does not represent a single definite scope. Rather, its meaning is contextual, relative to the program element at which it appears. The shared annotation is in some cases more flexible, in certain cases less flexible, but almost always simpler and easier to use than the approach taken by Java and C#. And it's a far better fit to a language like Ceylon with a regular, recursive block structure.

Members of a class are hidden from code outside the body of the class by default — only members explicitly annotated shared are visible to other toplevel types or methods, other compilation units, other packages, or other modules. A shared member is visible to any code to which the class itself is visible.

And, of course, a class itself may be hidden from other code. By default, a toplevel class is hidden from code outside the package in which the class is defined — only toplevel classes explicitly annotated shared are visible to other packages or modules. A shared toplevel class is visible to any code to which the package containing the class is visible.

Finally, a package may be hidden from packages in other modules. In fact, packages are hidden from code outside the module to which the package belongs by default — only explicitly shared packages are visible to other modules.

It's not possible to create a shared toplevel class with package-private members. Members of a shared toplevel class must be either shared — in which case they're visible outside the package containing the class, or un-shared — in which case they're only visible to the class itself. Package-private functionality must be defined in un-shared (package-private) toplevel classes or interfaces. Likewise, a shared package can't contain a module-private toplevel class. Module-private toplevel classes must belong to unshared (module-private) packages.

Ceylon doesn't have anything like Java's protected. The purpose of visibility rules and access control is to limit dependencies between code that is developed by different teams, maintained by different developers, or has different release cycles. From a software engineering point of view, any code element exposed to a different package or module is a dependency that must be managed. And a dependency is a dependency. It's not any less of a dependency if the code to which the program element is exposed is in a subclass of the type which contains the program element!

Abstracting state using attributes

The attribute greeting is a simple attribute, the closest thing Ceylon has to a Java field. Its value is specified immediately after it is declared. Usually we can declare and specify the value of an attribute in a single line of code.

shared String greeting = "Hello, " name "!";
shared Natural months = years * 12;

The Ceylon compiler forces us to specify a value of any simple attribute or local before making use of the simple attribute or local in an expression. Ceylon will never automatically initialize an attribute to any kind of default value or let code observe the value of an uninitialized attribute. This code results in an error at compile time:

Natural count;
shared void inc() {
    count++;   //compile error
}

An attribute is a bit different to a Java field. It's an abstraction of the notion of a value. Some attributes are simple value holders like the one we've just seen; others are more like a getter method, or, sometimes, like a getter and setter method pair. Like methods, attributes are polymorphic—an attribute definition may be refined (overridden) by a subclass.

We could rewrite the attribute greeting as a getter:

shared String greeting { 
    if (exists name) {
        return "Hello, " name "!";
    } 
    else {
        return "Hello, World!";
    }
}

Notice that the syntax of a getter declaration looks a lot like a method declaration with no parameter list.

Clients of a class never need to know whether the attribute they access holds state directly, or is a getter that derives its value from other attributes of the same object or other objects. In Ceylon, you don't need to go around declaring all your attributes private and wrapping them in getter and setter methods. Get out of that habit right now!

Understanding object initialization

In Ceylon, classes don't have constructors. Instead:

  • the parameters needed to instantiate the class — the initializer parameters — are declared directly after the name of the class, and
  • code to initialize the new instance of the class — the class initializer — goes directly in the body of the class.

Take a close look at the following code fragment:

String greeting; 
if (exists name) {
    greeting = "Hello, " name "!";
}
else {
    greeting = "Hello, World!";
}

In Ceylon, this code could appear in the body of a class, where it would be declaring and specifying the value of an immutable attribute, or it could appear in the body of a method definition, where it would be declaring and specifying the value of an immutable local variable. That's not the case in Java, where initialization of fields looks very different to initialization of local variables! Thus the syntax of Ceylon is more regular than Java. Regularity makes a language easy to learn and easy to refactor.

Now let's turn our attention to a different possible implementation of greeting:

class Hello(String? name) {
    shared String greeting { 
        if (exists name) {
            return "Hello, " name "!";
        } 
        else {
            return "Hello, World!";
        }
    } 
    ...

}

You might be wondering why we're allowed to use the parameter name inside the body of the getter of greeting. Doesn't the parameter go out of scope as soon as the initializer terminates? Well, that's true, but Ceylon is a language with a very strict block structure, and the scope of declarations is governed by that block structure. In this case, the scope of name is the whole body of the class, and the definition of greeting sits inside that scope, so greeting is permitted to access name.

We've just met our first example of closure. We say that method and attribute definitions receive a closure of values defined in the class body to which they belong. That's just a fancy way of obfuscating the idea that greeting holds onto the value of name, even after the initializer completes.

In fact, one way to look at the whole notion of a class in Ceylon is to think of it as a function which returns a closure of its own local variables. This helps explain why the syntax of class declarations is so similar to the syntax of method declarations (a class declaration looks a lot like a method declaration where the return type and the name of the method are the same).

Instantiating classes and overloading their initializer parameters

Oops, I got so excited about attributes and closure and null value handling that I forgot to show you the code that uses Hello!

doc "Print a personalized greeting" 
void hello() {
    Hello(process.args.first).say(process.output);
}

Our rewritten hello() method just creates a new instance of Hello, and invokes say(). Ceylon doesn't need a new keyword to know when you're instantiating a class. No, we don't know why Java needs it. You'll have to ask James.

I suppose you're worried that if Ceylon classes don't have constructors, then they also can't have multiple constructors. Does that mean we can't overload the initialization parameter list of a class?

I guess now's as good a time as any to break some more bad news: Ceylon doesn't support method overloading either! But, actually, this isn't as bad as it sounds. The sad truth is that overloading is the source of various problems in Java, especially when generics come into play. And in Ceylon, we can emulate most non-evil uses of constructor or method overloading using:

  • defaulted parameters, to emulate the effect of overloading a method or class by arity (the number of parameters),
  • sequenced parameters, i.e. varargs, and
  • union types or enumerated type constraints, to emulate the effect of overloading a method or class by parameter type.

We're not going to get into all the details of these workarounds right now, but here's a quick example of each of the three techniques:

//defaulted parameter 
void print(String string = "\n") {
    writeLine(string);
}
//sequenced parameter 
void print(String... strings) {
    for (String string in strings) { 
        writeLine(string);
    }
}
//union type 
void print(String|Named printable) {
    String string;
    switch (printable) 
    case (is String) {
        string = printable;
    } 
    case (is Named) {
        string = printable.name;
    }
    writeLine(string);
}

Don't worry if you don't completely understand the third example just yet. Just think of it as a completely typesafe version of how you would write an overloaded operation in a dynamic language like Smalltalk, Python, or Ruby. (If you're really impatient, skip forward to the discussion of generic type constraints.)

To be completely honest, there are some circumstances where this approach ends up slightly more awkward than Java-style overloading. But that's a small price to pay for a language with clearer semantics, without nasty corner cases, that is ultimately more powerful.

Let's overload Hello, and its say() method, using defaulted parameters:

doc "A command line greeting" 
class Hello(String? name = process.args.first) {
	...
    
    doc "Print the greeting" 
    shared void say(OutputStream stream = process.output) {
        stream.writeLine(greeting);
    }
    
}

Our hello() method is now looking really simple:

doc "Print a personalized greeting" 
void hello() {
    Hello().say();
}

There's more...

In Part 3, we'll explore inheritance and refinement (overriding).


Back to top