User Tools

Site Tools


Sidebar

Jump to
AmbientTalk
CRIME
iScheme

at:tutorial:modular

Modular Programming

In this tutorial chapter, we introduce AmbientTalk's language features that have to do with writing modular programs. The term “modular” can be taken quite broadly in this context. By “modular” programming, we here mean programs hierarchically structured into multiple files and objects composed from multiple so-called “trait” objects. We also describe AmbientTalk's lightweight support for classifying objects and how this enables exception handling based on “types”.

Namespaces and the lobby

In AmbientTalk, programs are primarily partitioned into multiple files. Hence, a file can be regarded as the most coarse-grained module in an AmbientTalk system. Each file has its own namespace: a “global” variable is only globally visible in the file in which it has been defined. There is only one exception to this rule: definitions in the “init” file (which is by default the file at/init/init.at available in the system library) are considered global to all files.

Namespaces

In order for AmbientTalk programs to use code defined in other files, a file loading mechanism is required. AmbientTalk provides the concept of a namespace object to perform this task. A namespace object is an object which is internally connected to a directory path on the local file system (how a namespace object is tied to this path will be explained later). By accessing a namespace object's slots, files can be loaded. Consider, for example, a namespace object stored in the variable lib which is internally tied to the directory /home/ambienttalkuser/examples. Also consider that in the examples directory, there exists a file named math.at with the following contents:

object: {
  def factorial(n) {
    if: (n = 0) then:  {
      1
    } else: {
      n*factorial(n-1)
    }
  };
  def fib(n) {
    if: (n <= 1) then: {
      n
    } else: {
      fib(n-1)+fib(n-2)
    }
  };
};

This file can now be loaded by means of the lib namespace object as follows:

def mathModule := lib.math;
system.println(mathModule.factorial(5));

When the namespace object's math slot is accessed for the first time, the namespace object checks whether its encapsulated directory contains:

  • a file named math.at
  • a subdirectory named math

In this example, the file math.at exists in the directory /home/ambienttalkuser/examples. In this case, the file is loaded and the result of evaluating the code in that file is bound to the math slot. Recall that the math.at file defined one object with two methods. The above code snippet shows how these methods can now simply be used in the context of another file.

If the math slot is accessed a second time, the math.at file will not be loaded again. Rather, the math slot simply contains the return value from the loaded code. Hence, namespaces provide the desirable semantics that a file is loaded only the first time it is required. If the math.at file would not have existed, the call lib.math may still have succeeded if there would have existed a subdirectory named math in lib's directory. In that case, lib's math slot would be bound to a new namespace object encapsulating the directory /home/ambienttalkuser/examples/math.

By representing hierarchical directories as nested namespace objects, the AmbientTalk programmer can abstract over the actual files and directories of the underlying file system. For example, imagine that the designer of the math.at file later decides to change the file structure of his library by creating a directory named math and by placing the code of each function in a separate file (named factorial.at and fib.at). The code defined above would not be affected: the expression lib.math would then evaluate to a namespace object for the math subdirectory, and the expression mathModule.factorial would load the file factorial.at which returns a closure that is immediately invoked.

Obviously, this approach works both ways: if the library designer had started out organizing his project using subdirectories and multiple files, he can always restructure the library's file organization without breaking the interface to clients by replacing a subdirectory with an AmbientTalk file that evaluates to an object whose public slot names correspond to the file names of the original subdirectory.

The lobby

We have yet to explain how namespaces are initially tied to directories. One possibility is to define a root variable which would be bound to the “/” directory (i.e. the root directory of the file system). However, to load the contents of the file /home/ambienttalkuser/examples/math.at, this would require writing:

root.home.ambienttalkuser.examples.math

The downside of such as scheme is that the absolute path name of the math.at file is hardcoded in the AmbientTalk application. If we would later decide to move the file, our AmbientTalk code using that file would be broken! To avoid these problems, AmbientTalk provides the lobby object. The lobby is an object defined in the top-level scope whose slots are bound to namespace objects. The names of the slots, and the directory paths of the namespace objects bound to them are not provided in AmbientTalk directly, but rather when starting the AmbientTalk interpreter.

The interactive ambienttalk shell (iat) has a command-line parameter called the object path. This argument is a list of “name=path” entries separated by colons on Mac/Linux or semicolons on Windows. For example, in order to create a namespace object called lib which is bound to /home/ambienttalkuser/examples, it suffices to start iat as follows:

iat -o lib=/home/ambienttalkuser/examples

Whenever a new actor is created by the AmbientTalk interpreter, it uses the entries in the object path to initialize the lobby object of the actor. Hence, by starting up the interpreter as shown above, executing lobby.lib in any actor returns a reference to a namespace object bound to the examples directory. This effectively removes absolute path names from AmbientTalk source code and even enables you to quickly reconfigure AmbientTalk code to use other libraries by loading the code using another object path.

Think of AmbientTalk's object path as the equivalent of Java's classpath.

AmbientTalk provides an alias for the lobby variable named /. Hence, rather than writing lobby.lib, it is also possible to write /.lib which is often more convenient, although perhaps a bit more cryptic.

The current namespace

AmbientTalk provides a slot named ~ which is always bound to the “current namespace”, the namespace in which the current file is contained. This is useful for loading files in a relative manner. For example, in the math.at file defined above, evaluating ~ would result in the lib namespace. Hence, if there would be a file named discretemath.at in the directory /home/ambienttalkuser/examples, then the math.at file could load it by writing:

def discreteMathModule := ~.discretemath;

Loading a module this way is useful because the author of math.at does not necessarily know of the lib namespace. Also, using lobby.lib introduces the implicit requirement that an object path for lib must be given. The slot ~ is always well-defined, so using it does not introduce additional dependencies.

Importing objects

The previous section has shown how the result of evaluating the content of an external file can be accessed from within another file. One file almost always defines multiple useful function or object definitions. As shown previously, multiple definitions can be returned to clients loading the file by returning an object and making the definitions fields or methods of the returned object. We sometimes refer to such objects as module objects, although they are ordinary objects with no special properties.

As shown in the example code above, functionality from a module object can be used simply by accessing its fields or invoking one of its methods. Again, it is not wrong to think of any object as a small kind of module. Sometimes, functionality from a module object is used so often in an importing file that it is worth redefining the function such that it can be accessed unqualified. For example, if a lot of factorials have to be calculated, it is worth defining:

def factorial := mathModule.factorial;

The factorial function has been selectively imported into the defining namespace. This pattern of using objects to represent modules and of defining local aliases for a module's functionality to represent a module import can usually take you a long way. However, sometimes it is useful to be able to import all functionality provided by an object. In that case it becomes tedious to alias every slot of the imported object. Moreover, if the interface would later change, all of the importing clients would have to be modified as well. To avoid such issues, AmbientTalk provides language support in the form of the import statement. The import statement takes an object as an argument and creates local aliases for all of the slots of its argument object. For example:

import mathModule;

is equivalent to:

def factorial := mathModule.factorial;
def fib := mathModule.fib;

When an object has been imported, all of its local slots become lexically visible. This, of course, introduces the danger that previously lexically visible definitions become shadowed by the import. Hence, import may accidentally breach the encapsulation of the importing object because the imported object may have defined slots with the same name. If you don't want to run that risk, simply keep the scopes of the importing and imported objects separate and access the module object by means of qualified access as before.

Warning: it may seem useful to sometimes be able to import a namespace object. For example, it may seem useful to import the lib namespace object such that the files math.at and discretemath.at become more easily accessible:
import lib;
system.println(math.factorial(5));

However, it is advised not to import namespace objects like this. The reason is that a namespace object is initially empty, and adds slots to itself dynamically whenever a slot corresponding to a file or subdirectory is accessed for the first time. Hence, importing a namespace object often boils down to importing an empty object, obviously not the semantics the programmer had in mind. Loading an entire namespace object is not supported as it would encompass loading all unloaded files in the namespace's directory at once. We feel that using an import statement to do this is rather implicit.

Objects as traits

In this section, we describe how the import statement can also be used to compose multiple objects. In this respect, objects can be regarded as traits. Traits are a model of object-oriented software composition, similar to (but more advanced than) mixins. A trait can be regarded as a kind of abstract class, which does not define any state, which provides a set of methods and which requires a set of methods in order to be instantiated. In a class-based language, a class may use one or more traits. The class takes on the role of compositor and is responsible for ensuring that, once all traits have been imported, each trait its required interface is provided by the class or other imported traits. A trait is almost literally a piece of code which can be “copy-pasted” into a class.

import as trait composition

As an example, consider the typical functionality of an “enumerable” object. As the previous sentence made clear, being “enumerable” is a trait: it is an abstract property of an object. Let's define (part of) what behaviour enumerable objects should provide:

def Enumerable := object: {
  // map the closure over the collection
  def collect: clo {
    def result := [];
    self.each: { |e| result := result + [clo(e)] };
    result
  };
  // return an element in enumeration for which pred returns true
  def detect: pred {
    { |return|
      self.each: { |e|
        if: pred(e) then: return(e)
      };
      nil }.escape();
  };
  // return all elements for which pred returns false
  def reject: pred {
    def result := [];
    self.each: { |e| if: !pred(e) then: { result := result + [e] } };
    result
  };
}

As can be seen from the method bodies of the Enumerable object, all implementations rely on the presence at runtime of an each: method available in the receiver. When viewed as a trait, this enumerable object provides the behaviour to collect, detect and reject elements from any enumeration but requires that enumeration to implement the each: method.

Using the import statement introduced in the previous section, it becomes easy to actually use the Enumerable object as a trait, by importing it into a composite object:

def Range := object: {
  import Enumerable;
  def start := 0;
  def end := 0;
  def init(from,to) {
    start := from; end := to;
  };
  def each: clo {
    start.to: end-1 do: clo
  };
};
Range.new(0,10).reject: { |i| (i%3 != 0) }
>>[0,3,6,9]

In this example, Range is a composite object which imports the methods defined by the Enumerable object. However, how is it possible that Enumerable uses Range's definition of each:? This is because the import statement does not simply alias the binding for methods, but rather defines a delegate method for each imported method, as follows:

def Range := object: {
  // import Enumerable is translated into:
  def collect: clo { Enumerable^collect: clo };
  def detect: pred { Enumerable^detect: pred };
  def reject: pred { Enumerable^reject: pred };
  ... // previous definitions from Range
};

So, import defines small methods which delegate the request to the original trait (using AmbientTalk's support for explicit delegation). The use of delegation is crucial here: it means that within the context of the trait, self is bound to the object using the trait. Hence, when reject: is invoked on a Range object, Enumerable's self.each: will refer to the correct implementation of Range.

Note that in AmbientTalk, a trait does not need to explicitly specify the set of methods which it “requires” from its composite. However, for documentation purposes, it is often very useful to explicitly state the methods on which a trait depends. One may do so by defining those methods in the trait, and annotating them with the @Required type tag (which is predefined in the lobby.at.lang.types module). These methods will not be imported when the trait is used by a composite object (otherwise they would cause a conflict with the real implementation methods in the composite). Below is an example of how the Enumerable trait can be modified to explicitly state its required methods:

def Enumerable := object: {
  def collect: clo { /* as before */ };
  def detect: pred { /* as before */ };
  def reject: pred { /* as before */ };
  def each: clo @Required;
}

Resolving conflicts: exclusion and aliasing

One of the advantages of trait composition is that any conflicts (name clashes) are resolved at trait composition time. Contrast this with e.g. mixin-based composition, where one mixin simply shadows the previous one, which may lead to unexpected behaviour at runtime. The object that imports one or more traits is always responsible for resolving name clashes. A name clash between an already existing slot and a new, imported slot can be solved in two ways: either the imported slot from the trait can be excluded, preferring the existing slot instead or the imported slot can be renamed such that both slots can co-exist in the same object.

The import statement contains optional alias and exclude clauses to allow the importing object to state which slots should not be imported, and which slots should be imported under a different name. The syntax is self-explanatory:

// do not import the slots collect: and detect:
import Enumerable exclude collect:, detect:
// do not import collect: and import reject: as remove:
import Enumerable alias reject: := remove: exclude collect:

If the compositor defines an alias for an imported slot, it is good practice to ensure that the compositor has (or imports) a slot with the original name as well. That way, if the trait object performs a self-send to invoke one of its own methods, it will find a matching slot in the compositor. If the compositor aliases a slot and does not define the slot itself, a lookup by the trait of the original slot name would fail.

import adds two names to the exclude clause by default:
  • super (the parent object) because this slot is present in all AmbientTalk objects.
  • ~ (the current namespace), because this slot is present in all namespace objects.

Moreover, all methods of the trait object annotated with @Required are automatically excluded upon import.

Classifying objects using type tags

In class-based languages, classes are a useful tool for categorising objects. Each object is an instance of some class, and sometimes it is useful to be able to ask to which class an object belongs. However, it is well-known by proponents of object-oriented programming that explicitly referring to the class of an object breaks that object's encapsulation. The reason is that a class plays other roles besides object classification. Classes also define an implementation for the object's methods. Hence, knowing the class of an object often boils down to knowing the exact implementation of an object, which no longer makes the code polymorphic. This, in turn, means that the code becomes less reusable and more easily breaks when the software evolves over time (because of the addition/removal of classes).

Nevertheless, the ability to classify objects according to abstract “types” (we don't use the term “classes” to avoid any confusion) is sometimes useful, especially when the “type-test” cannot be properly refactored into a polymorphic message send. To this end, AmbientTalk provides type tags. A type tag is simply a brand or marker which can be attached to an object. Type tags are defined using the deftype keyword. A type tag is identified by its name. For example, we may want to distinguish indexable from non-indexable objects:

deftype Indexable;

Since a type tag is an abstract type or category, it makes sense to define subtype relations on them. Also, since type tags do not carry an implementation, there is no problem in defining a type tag to be a subtype of one or more other type tags. For example, we may want to state that a sortable object is always enumerable as well as ordered:

deftype Enumerable;
deftype Ordered;
deftype Sortable <: Enumerable, Ordered;

When defining an object, the object can be tagged with one or more type tags.

def Array := object: {
  ...
} taggedAs: [ Indexable, Sortable ]

Finally, the most useful operation defined on type tags is the “type test”: it allows objects to test whether an object is tagged with the appropriate type tag.

is: Array taggedAs: Indexable
>> true
is: Array taggedAs: Ordered
>> true
is: Array taggedAs: Set
>> false

The type test determines whether an object or one of its parents is tagged with the given type tag or a subtype of the type tag.

The type tags with which an object has been tagged can be retrieved by means of the tagsOf: primitive:

tagsOf: Array
>> [ <type tag:Indexable>, <type tag:Sortable> ]
Type tags can best be compared to empty Java interface types. Such empty interfaces are sometimes used in Java purely for the purposes of marking an object. Examples are java.io.Serializable and java.lang.Cloneable. An empty interface type can be implemented by any class (object) and hence only serves the purpose of distinguishing objects by type (by means of instanceof in Java).

Native Type Tags

The module /.at.lang.types shipped with the system library contains the type definitions of the native data types of the interpreter. These type tags can be used to perform type tests on objects, for example:

is: 1 taggedAs: /.at.lang.types.Number
>> true
is: "foo" taggedAs: /.at.lang.types.Text
>> true

The type /.at.lang.types.Isolate can be used to mark an object as an isolate. Isolate objects are similar to serializable Java objects. They are explained when introducing concurrent programming in a later chapter.

Type tags as annotated message sends

In AmbientTalk, messages are objects as well. Quite often, it is useful to tag a message with a type tag that indicates how the message should be processed. AmbientTalk provides syntax for such annotations, as follows:

obj.m(a,b,c)@tag
obj.m(a,b,c)@[tag1, tag2]

In the chapter on actors we use this feature to distinguish purely asynchronous message sends from so-called future-type message sends. If the message send is annotated with the FutureMessage type tag, the asynchronous send returns a future, otherwise it returns nil.

Exception Handling

AmbientTalk employs a traditional exception handling mechanism based on try-blocks. The try:catch:using: primitive enables the handling of exceptions raised within the dynamic execution path of the try-block. For example:

try: {
  calculateSomething();
} catch: DivisionByZero using: { |e|
  system.println(e.message);
}

The first argument is a closure to execute, delineating a dynamic piece of code to protect with the given exception handler. The third argument is a one-argument closure, invoked when an exception is caught of the right kind. The second argument is a type tag. By default, exceptions are handled based on their type tags. A hierarchy of type tags is used to classify exceptions. Exception handling in AmbientTalk is quite conventional: when a method is raised, the call stack is unwound until an exception handler installed using try:catch:using: is found whose type tag argument is a supertype of the raised exception's type. If no matching handlers are found, the exception continues unwinding the call stack until either a handler is found or the stack is entirely unwound. In the latter case, a stack trace is printed.

Raising an exception is done by means of the raise: primitive:

if: (denominator == 0) then: {
  raise: XDivisionByZero.new("division by zero");
}

Note that a new instance of an object named XDivisionByZero is raised, not the type tag DivisionByZero itself. An exception is any object that is tagged with (a subtype of) the lobby.at.lang.types.Exception type tag. By convention, an exception should have the fields message and stackTrace. The exception module found under lobby.at.lang.exceptions contains an auxiliary function named createException which takes a type tag as argument and returns a new prototype exception object. For example:

deftype DivisionByZero <: lobby.at.lang.types.Exception;
def XDivisionByZero := lobby.at.lang.exceptions.createException(DivisionByZero);

Exceptions raised by the interpreter

The lobby.at.exceptions module contains predefined prototypes for exceptions that may be raised by the interpreter which are sometimes useful to catch. Examples are:

  • XArityMismatch, raised when a function is passed the wrong number of arguments
  • XParseError, raised when the parser cannot parse AmbientTalk source code
  • XSelectorNotFound, raised when method lookup fails

Custom exception handlers

You can specify any object to act as a first-class “exception handler” provided that the object understands the following minimal protocol. An exception handler has to implement a method canHandle(exc) which takes a raised exception object and which should return a boolean value, indicating whether the handler can handle that exception. It also has to implement a method handle(exc) which allows it to handle an exception for which its canHandle method returned true. First-class exception handlers are installed by means of the try:using: primitive. We could rewrite the above “division by zero” example as:

try: {
  calculateSomething();
} using: (object: {
  // this is a first-class handler object
  def canHandle(exc) {
    is: exc taggedAs: DivisionByZero
  };
  def handle(exc) {
    system.println(exc.message);
  };
} taggedAs: [/.at.lang.types.Handler])

First-class exception handlers are sometimes useful to factor out common exception handling behaviour, and also to specify more complex boolean conditions, e.g. one can express that only SelectorNotFound exceptions should be caught where the name of the selector that was not found equals foo.

Catching multiple kinds of exceptions

AmbientTalk defines a number of convenience methods that allow you to list up to three catch:using: clauses. For example:

try: {
  calculateSomething();
} catch: DivisionByZero using: { |e|
  system.println(e.message);
} catch: NoSolution using: { |e|
  calculateSomethingElse();
};

If more clauses are required, either nested try:catch:using: calls can be used or the method try:usingHandlers: can be used. This primitive takes a closure to invoke and a table of first-class handler objects which will be sequentially consulted if an exception is raised during execution of the closure.

Care has to be taken that handlers are listed in increasing order of “generality”. If the most general handler (e.g. a handler for Exception) is listed first, other handlers will not have been consulted and will never trigger.

Escaping Continuations

It is often useful to be able to abort the control flow within a method prematurely. In traditional imperative programming languages, this is done by means of a return statement (or the ^ in Smalltalk). AmbientTalk inherits a similar control structure from the Self programming language. Rather than introducing the notion of a return statement, AmbientTalk uses the more general concept of an escaping continuation to achieve a similar effect. Consider the following code which tests whether a given table tbl contains an element elt:

def contains(tbl, elt) {
  { |return|
    1.to: tbl.length do: { |i|
      if: (tbl[i] == elt) then: {
        return(true)
      }
    };
    false
  }.escape()
}

When escape() is invoked on a block closure, the effect is to apply that block closure to a special function object (the escaping continuation). That object, named return in the example, behaves as a function of one argument. When that function is invoked, control immediately returns to the end of the block, and the value passed to the escaping continuation is the result of the escape() call. If the function object is not called, the closure returns normally and its return value is the result of the escape() call.

As can be seen, an escaping continuation is more general than a return statement: the continuation is a first-class function object and can hence be passed on to other objects. Hence, it is possible to return from multiple nested function calls at once.

There is an important limitation to the use of escaping continuations. An escaping continuation is not a full-fledged continuation (such as the one provided by Scheme's call/cc): the escaping continuation is valid only in the execution of the block closure. Once control has returned from the block (either because it terminated normally with a value, or by means of the escaping continuation), the escaping continuation can no longer be used. If the continuation function object is e.g. stored in a variable and applied later, when the block already terminated, this will result in an exception being raised.

at/tutorial/modular.txt · Last modified: 2013/05/17 20:24 by tvcutsem