User Tools

Site Tools


Sidebar

Jump to
AmbientTalk
CRIME
iScheme

at:tutorial:modular

This is an old revision of the document!


Modular Programming

This Tutorial is still under heavy construction!

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. 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.

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 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. 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.

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.

Classifying objects using stripes

explain: what are stripes? what kind of objects are they, stripe subtyping, stripe test, what default stripes exist

Exception Handling

explain: raise, try-catch and variants, first-class handlers, role of stripes, interface of an exception object

at/tutorial/modular.1177441057.txt.gz · Last modified: 2007/04/24 20:58 (external edit)