[go: up one dir, main page]

Academia.eduAcademia.edu
The Converge programming language Technical report TR-05-01, Department of Computer Science, King’s College London Laurence Tratt laurie@tratt.net February 26, 2005 1 Contents 1 Introduction 2 Converge basics 2.1 Syntax, scoping and modules . . . . . . . 2.2 Functions . . . . . . . . . . . . . . . . . 2.3 Goal-directed evaluation . . . . . . . . . 2.3.1 While loops . . . . . . . . . . . . 2.4 Data model . . . . . . . . . . . . . . . . 2.4.1 Built-in data types . . . . . . . . 2.5 Comparisons and comparison overloading 2.6 Exceptions . . . . . . . . . . . . . . . . . 2.7 Meta-object protocol . . . . . . . . . . . 2.8 Differences from Python . . . . . . . . . 2.9 Differences from Icon . . . . . . . . . . . 2.10 Implementation . . . . . . . . . . . . . . 2.11 Parsing . . . . . . . . . . . . . . . . . . 2.12 Related work . . . . . . . . . . . . . . . 3 4 4 . . . . . . . . . . . . . . 4 5 6 6 9 9 11 11 12 12 12 13 13 14 16 . . . . . . . . . . . . . . . . . . . 16 16 17 18 19 19 20 20 22 22 22 23 24 24 26 26 28 30 30 32 Implications of Converge’s compile-time meta-programming for other languages and their implementations 4.1 Language design implications . . . . . . . . . . . . . . . . . . . . . 33 34 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Compile-time meta-programming 3.1 Background . . . . . . . . . . . . . . . . . . . . . 3.2 A first example . . . . . . . . . . . . . . . . . . . 3.3 Splicing . . . . . . . . . . . . . . . . . . . . . . . 3.3.1 Permissible splice locations . . . . . . . . 3.4 The quasi-quotes mechanism . . . . . . . . . . . . 3.4.1 Splicing within quasi-quotes . . . . . . . . 3.5 Basic scoping rules in the presence of quasi-quotes 3.6 The CEI interface . . . . . . . . . . . . . . . . . . 3.6.1 ITree functions . . . . . . . . . . . . . . . 3.6.2 Names . . . . . . . . . . . . . . . . . . . 3.7 Lifting values . . . . . . . . . . . . . . . . . . . . 3.8 Dynamic scoping . . . . . . . . . . . . . . . . . . 3.9 Forward references and splicing . . . . . . . . . . 3.10 Compile-time meta-programming in use . . . . . . 3.10.1 Conditional compilation . . . . . . . . . . 3.11 Run-time efficiency . . . . . . . . . . . . . . . . . 3.12 Compile-time meta-programming costs . . . . . . 3.13 Error reporting . . . . . . . . . . . . . . . . . . . 3.14 Related work . . . . . . . . . . . . . . . . . . . . 2 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.2 4.3 Compiler structure . . . . . . . . . . . . . . . . . . . . . . . . . . . Compiler interface . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.3.1 Abstract syntax trees . . . . . . . . . . . . . . . . . . . . . . 34 36 36 5 Syntax extension for DSL’s 5.1 DSL implementation functions . . . . . . . . . . . . . . . . . . . . . 5.2 Related work . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 37 38 38 6 Modelling language DSL 6.1 Example of use . . . . . . . . . 6.2 Data model . . . . . . . . . . . 6.3 Pre-parsing and grammar . . . . 6.4 Traversing the parse tree . . . . 6.5 Translating . . . . . . . . . . . 6.5.1 OCL expressions . . . . 6.5.2 Forward references . . . 6.5.3 Model class translation . 6.5.4 Summary of translation . 6.6 Diagrammatic visualization . . . 39 39 41 43 44 45 45 46 46 47 47 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7 Future work 48 8 Acknowledgments 49 A Converge grammar 50 B The ‘Simple UML’ modelling language translation 53 3 1 Introduction This paper details the Converge programming language, a new dynamically typed imperative programming language capable of compile-time meta-programming, and with an extendable syntax. Although Converge has been designed with the aim of implementing different model transformation approaches as embedded DSL’s in mind, it is also a General Purpose Language (GPL), albeit one with unusually powerful features. The motivation for a new approach to implementing model transformation approaches is simple: existing languages, and their associated tool-chains, lead to long and costly implementation cycles for model transformation approaches. The justification for creating a new language, rather than altering an existing one, is far less obvious — it is reasonable to suggest that, given the vast number of programming languages already in existence, one of them should present itself as a likely candidate for modification. There are two reasons why a new language is necessary to meet the aims of this paper. Firstly, in order to meet its aims, Converge contains a blend of features unique amongst programming languages; some fundamental design choices have been necessary to make these features coalesce, and imposing such choices retrospectively on an existing language would almost certainly lead to untidy results and backwards compatibility issues. Secondly, my personal experience strongly suggests that the complexity of modern languages implementations (when such implementations are available) can make adding new features a significant challenge. In short, I assert that it is easier in the context of model transformations to start with a fresh canvass than to alter an existing language. This paper comes in three main parts. The first part documents the basics of the Converge language itself;. The second part details Converge’s compile-time metaprogramming and syntax extension facilities, including a section detailing suggestions for how some of Converge’s novel features could be added to similar languages. The third part of this paper explains Converge’s syntax extension facility, and documents a user extension which allows simple UML-esque modelling languages to be embedded within Converge. As well as being a practical demonstration of Converge’s features, this facility is used extensively throughout the remainder of the paper. 2 Converge basics This section gives a brief overview of the core Converge features that are relevant to the main subject of this paper. Since most of the basic features of Converge are similar to other similar programming language, this section is intentionally terse. However it should allow readers familiar with a few other programming languages the opportunity to quickly come to grips with the most important areas of Converge, and to determine the areas where it differs from other languages. 4 2.1 Syntax, scoping and modules Converge’s most obvious ancestor is Python [vR03] resulting in an indentation based syntax, a similar range and style of datatypes, and general sense of aesthetics. The most significant difference is that Converge is a slightly more static language: all namespaces (e.g. a modules classes and functions, and all variable references) are determined statically at compile-time whereas even modern Python allows namespaces to be altered at run-time1. Converge’s scoping rules are also different from Python’s and many other languages, and are intentionally very simple. Essentially Converge’s functions are synonymous with both closures and blocks. Converge is lexically scoped, and there is only one type of scope (as opposed to Python’s notion of local and global scopes). Variables do not need to be declared before their use: assigning to a variable anywhere in a block makes that variable local throughout the block (and accessible to inner blocks) unless the variable is declared via the nonlocal keyword to refer to a variable in an outer block. Variable references search in order from the innermost block outwards, ultimately resulting in a compile-time error if a suitable reference is not found. As in Python, fields within a class are not accessible via the default scoping mechanism: they must be referenced via the self variable which is automatically brought into scope in any bound function (functions declared within a class are automatically bound functions). Converge’s justification for this is subtly different than Python’s, which has this feature to aid comprehension; although this is equally true in Converge, without this feature, namespaces would not be statically calculable since an objects slots are not always known at compile-time. Converge programs are split into modules, which contain a series of definitions (imports, functions, classes and variable definitions). Unlike Python, each module is individually compiled into a bytecode file by the Converge compiler convergec and linked by convergel to produce a static bytecode executable which can be run by the Converge VM. If a module is the main module of a program (i.e. passed first to the linker), Converge calls its main function to start execution. The following module shows a caching Fibonacci generating class, and indirectly shows Converge’s scoping rules (the i and fib cache variables are local to the functions they are contained within), printing 8 when run: import Sys class Fib_Cache: func init(): self.cache := [0, 1] func fib(x): i := self.cache.len() while i <= x: self.cache.append(self.cache[i - 2] + self.cache[i - 1]) i += 1 return self.cache[x] func main(): fib_cache := Fib_Cache() Sys.println(fib_cache.fib(6)) 1 Prior to version 2.1, Python’s namespaces were determined almost wholly dynamically; this often lead to subtle bugs, and hampered the utility of nested functions. 5 Compiling and running this fragment looks as follows: $ converge convergec -o fib.cvb fib.cv $ converge convergel -o fib fib.cvb lib/libconverge.cvl $ converge fib 8 As in Python, Converge modules are executed from top to bottom when they are first imported. This is because functions, classes and so on are normal objects within a Converge system that need to be instantiated from the appropriate builtin classes – therefore the order of their creation can be significant e.g. a class must be declared before its use by a subsequent class as a superclass. Note that this only effects references made at the modules top-level – references e.g. inside functions are not restricted thus. 2.2 Functions Converge uses the term function both in its traditional programming sense of a standalone function (or ‘procedure’), and also for functions which reside in classes (often called methods). The reason for this is that ‘normal’ functions and ‘methods’ are not restricted in Converge to only their traditional rôles: ‘normal’ functions can reside in classes and ‘methods’ can reside outside of classes. When it is important to distinguish between the two, Converge has two distinct types: unbound functions (‘normal’ functions) and bound functions (‘methods’). Bound functions expect to have an implicit first argument of the self object2 ; however they can not have arguments applied to it directly. Extracting a bound function from an object creates a function binding which wraps a bound function and a reference to the self object into an object which can then have arguments applied to it. Function bindings can be manually created by instantiating the Func Binding class, which allows bound functions to be used with arbitrary self objects. In normal use, Converge automatically assumes that the keyword func introduces an unbound function if it is used outside class, and a bound function if used inside a class. Using the bound func or unbound func keywords overrides this behaviour. Functions, bound or unbound, can have zero or more parameters; prefixing the final parameter in a function with a * denotes the ‘var args’ parameter. An important feature of functions is their apply slot which applies a list of objects as parameters to the function. This allows argument lists of arbitrary size to be built and applied at run-time. 2.3 Goal-directed evaluation An important, if less obvious, influence to Converge is Icon [GG96a]. Since Icon is likely to be unfamiliar to be most readers, a brief overview of Icon is instructive in understanding why it possesses an unusual, and interesting, feature set. Icon’s chief designer was Ralph Griswold, and is a descendant of the SNOBOL series of programming languages – whose design team Griswold had been a part of – and SNOBOL’s 2 Note that unlike Python, Converge does not force the user to explicitly list self as a function parameter. 6 short-lived successor SL5. SNOBOL4 in particular was specifically designed for the task of string manipulation, but an unfortunate dichotomy between pattern matching and the rest of the language, and the more general problems encountered when trying to use it for more general programming issues ensured that, whilst successful, it never achieved mass acceptance; SL5 suffered from almost the opposite problem by having an over-generalized and unwieldy procedure mechanism. See Griswold and Griswold [GG93] for an insight into the process leading to Icon’s conception. Since programs rarely manipulate strings in isolation, post-SL5 Griswold had as his aim to build a language which whilst being aimed at non-numeric manipulation also was usable as a general programming language. The eventual result was Icon [GG96a, GG96b], a language still in use and being developed to this day. In order to fulfil the goal of practical string manipulation, the premises on which Icon is founded are not only fundamentally different from those normally associated with GPLs, but are also tightly coupled with one another. As Icon, Converge is an expression-based language, with similar notions of expression success and failure. In essence, expressions which succeed produce a value; expressions which fail do not produce a value and percolate the failure to their outer expression. For example the following fragment: func main(): x := 1 < 2 y := 2 < 1 Sys.println(x) Sys.println(y) leads to the following output: 2 Traceback (most recent call last): File "expr.cv", line 5, column 13, in main Unassigned_Var_Exception: Var has not yet been assigned to. This is because when the expression 2 < 1 is evaluated, it fails (since 2 is not less than 1); the failure percolates outwards and prevents the assignment of a value to the variable y. Note that failure does not percolate outwards to arbitrary points: failure can not cross bound expressions. A bound expression thus denotes a ‘stop point’ for backtracking. The most obvious point at which bound expressions occur is when expressions are separated by newlines in an Converge program although bound expressions occur in various other points. For example, each branch of an if expression is bound, which prevents the failure of a branch causing the entire if expression to be re-evaluated. Converge directly inherits Icon’s bound expression rules which largely preserve traditional imperative language evaluation strategies, even in the face of backtracking. Success and failure are the building blocks for goal-directed evaluation, which is essentially a limited form of backtracking suitable for imperative programming languages. Functions which contain the yield statement are generators and can produce more than one return value. The yield statement is an alternative type of function return which effectively freezes the current functions closure and stack, and returns a value to the caller; if backtracking occurs, the function is resumed from its previous point of execution and may return another value. Generators complete by using the 7 return statement. Since the return statement returns the null object if no expression is specified, generators typically use return fail to ensure that the completion of the generator does not cause one final loop of the caller — return’ing the fail object causes a function to percolate failure to its caller immediately. The most frequent use of generators is seemingly mundane, and occurs in the following idiom, which uses the iterate generator on a list to print each list element l on a newline: l := [3, 9, 27] for x := l.iterate(): Sys.println(x) In simple terms, the for construct evaluates its condition expression and after each iteration of the loop backtracks in an attempt to pump the condition for more values. This idiom therefore subsumes the clumsy encoding of iterators found in most OO languages. Generators can be used for much more sophisticated purposes. Consider first the following generator which generates all Fibonacci numbers from 1 to high: func fib(high): a, b := [0, 1] while b < high: yield b a, b := [b, a + b] return fail The for construct exhaustively evaluates its condition (via backtracking) until it can produce no more values. Therefore the following fragment prints all Fibonacci values from 1 to 100000: for f := fib(100000): Sys.println(f) The conjunction operator & conjoins two or more expressions; the failure of any part of the expression causes backtracking to occur. Backtracking resumes the most recent generator which is still capable of producing values, only resuming older generators when more recent ones are exhausted. Thus backtracking in Converge is entirely deterministic because the sequence in which alternatives are tried is explicitly specified by the programmer – this makes the evaluation strategy significantly different than that found in logic languages such as Prolog. If all expressions in a conjunction succeed, the value of the final expression is used as the value of the conjunction. If failure occurs, and there are no generators capable of producing more values to be resumed, then the conjunction itself fails. Combining the for construct with the & operator can lead to terse, expressive examples such as the following which prints all Fibonacci numbers wholly divisible by 3 between 1 and 100000: for Sys.println(f := fib(100000) & f % 3 == 0 & f) A brief explanation of this can be instructive. Firstly f := fib(100000) pumps the fib generator and assigns each value it returns to the variable f. Since it is contained within the first expression of the & operator, when the fib generator completes, 8 its failure causes the f := ... assignment to fail, which causes the entire & operator to fail thus causing the for construct to fail and complete. Secondly f % 3 == 0 checks whether f modulo 3 is equal to 0 or not; if it is not, failure occurs and backtracking occurs back to the fib generator. Since f % 3 == 0, if it succeeds, always evaluates to 0 (== evaluates to its right hand argument on success), the final expression of f produces the value of the variable which Sys.println then prints. Neither Icon or Converge possess standard boolean logic since equivalent functionality is available through other means. The conjunction operator acts as an ‘and’ operator. Although the disjunction operator | is generally used as ‘or’, it is in fact a generator that successively evaluates all its expressions, producing values for those expressions which succeed. Thus in most circumstances the | operator neatly preserves the normal expectation of ‘or’ – that it evaluates expressions in order only until it finds one which succeeds – whilst also providing useful extra functionality. This section has detailed the most important aspects of Converge’s Icon-esque features, but for a more thorough treatment of these features I recommend Icon’s manual [GG96a] — virtually all the material on goal-directed evaluation is trivially transferable from Icon to Converge. Gudeman [Gud92] presents a detailed explanation of goaldirected evaluation in general, with its main focus on Icon, and presents a denotational semantics for Icon’s goal-directed evaluation scheme. Proebsting [Pro97] and Danvy et al. [DGR01] both take subsets of Icon chosen for their relevance to goal-directed evaluation, compiling the fragments into various programming languages (Danvy et. al also specify their Icon subset with a monadic semantics); both papers provide solid further reading on the topic. 2.3.1 While loops Converge also contains a while construct. The difference between the for and while constructs is initially subtle, but is ultimately more pronounced than in most languages. In essence, each time a for loop completes, the construct backtracks to the condition expression and pumps it for a new value. In contrast, a while construct evaluates its expression anew after each iteration. This means that if the condition of a while construct is a generator it can only ever generate a maximum of one value before it is discarded. To emphasise this, the following code endlessly repeats, printing 1 on each iteration: while f := fib(100000): Sys.println(f) 2.4 Data model Converge’s OO features are reminiscent of Smalltalk’s [GR89] everything-is-an-object philosophy, but with a prototyping influence that was inspired by Abadi and Cardelli’s theoretical work [AC96]. The internal data model is derived from ObjVLisp [Coi87]. Classes are provided as a useful, and common, convenience but are not fundamental to the object system in the way they are to most OO languages. The system is bootstrapped with two base classes Object and Class, with the latter being a subclass of 9 Object slots : Dict{String : Object} conforms_to(Class) get_slot(String) : Object get_slots(String) : Dict{String : Object} has_slot(String) id() : Object init(*Object) set_slot(String, Object) to_str() : String instance_of supers * Class name : String fields : Dict{String : Object} is_subclass(Class) new(*Object) : Object Figure 1: Core Converge data model. the former and both being instances of Class itself: this provides a full metaclass ability whilst avoiding the class / metaclass dichotomy found in Smalltalk [BC89, DM95]. The core data model can be seen in figure 1. Note that the slots field in the Object class is conceptual and can only be accessed via the get slot and get slots functions. In the Smalltalk school of OO, objects consist of a slot for each attribute in the class; calling a method on an object looks for a suitable method in the object’s instantiating class (and possibly its superclasses). In contrast Converge, by default, creates objects with a slot for each field in a class, including methods. This therefore moves method overriding in classes to object creation time, rather than the more normal invocation time. This is possible since, as in Python, a functions name is the only factor to be taken into account when overriding. Object creation in Converge thus has a higher overhead than in most OO languages; this is offset by the fact that calling a function in an object is faster (since classes and super-classes do not need to be searched). The reason for this design decision is to ensure that all objects in a Converge system are ‘free objects’ in that they can be individually manipulated without directly affecting other objects, a feature which can prove useful when manipulating and transforming objects. This behaviour also mirrors the real world where, for example, changing a cars design on paper does not change actual cars on the road; it does not however reflect the behaviour of non-prototyping OO languages. For example, in Converge adding (or deleting) a method in a class does not automatically affect objects which are instances of that class, whereas in Python all of the classes instances would appear to grow (or lose) a method. From a practical point of view it is important to note that in normal use most users will be unaware of the difference between Converge’s object creation scheme and its more normal counterparts. Note that this entire area of behaviour can be overridden by using meta-classes and the meta-object protocol (section 2.7). In similar fashion to ObjVLisp, metaclasses are otherwise normal objects which possess a new slot. Class is the default metaclass; individual classes can instantiate 10 a different class via the metaclass keyword. Metaclasses typically subclass Class although this is not a requirement. A simple example of a useful metaclass is the following singleton metaclass [GHJV94] which allows classes to enforce that at most one instance of the class can exist in the system. Noting that exbi (EXtract and BInd) can be viewed as being broadly equivalent to other languages super keyword, the Singleton class is defined and used as follows: class Singleton(Class): func new(): if not self.has_slot("instance"): self.instance := exbi Class.new() return self.instance class M metaclass Singleton: pass Note that the new function in Class automatically calls the init function on the newly created object, passing it all the arguments that were passed to new. 2.4.1 Built-in data types Converge provides a similar set of built-in data types to Python: strings, integers, dictionaries (key / value pairs) and sets. Dictionary keys and set elements should be immutable (though this is not enforced, violating this expectation can lead to unpredictable results), and must define == and hash functions, the latter of which should return an integer representing the object. All built-in types are subclasses of Object, and can be sub-classed by user classes (although the current implementation restricts user classes to sub-classing a maximum of one built-in type). 2.5 Comparisons and comparison overloading Converge defines a largely standard set of binary operators. The lack of standard boolean logic in Converge means that the not operator is slightly unusual and is not classed as a comparison operator. Rather than not taking in a boolean value and returning its negated value, the not operator evaluates its expression and, if it fails, not succeeds and produces the null object. If the expression succeeds, the value produced is discarded and the not operator fails. Objects can control their involvement in comparisons by defining, or overriding, the functions which are called by the various comparison operators. Functions are passed an object for comparison, and should fail if the comparison does not hold, or return the object passed to them if it does. Comparison operators are essentially syntactic sugar for calling a function of the same name in the left hand side object (e.g. the == operator looks up the == slot in an object). Note that although the Converge grammar (section A) bundles the is operator into the comparison op production, it is unlike the other comparison operators in that it tests two objects for equality of their identities, and can not be overridden by user objects. 11 2.6 Exceptions Converge provides exception handling that is largely similar to Python. The raise expression raises an exception, printing a detailed stack-trace, the type of the exception and a message from the exception object itself. All exceptions must be instances of the Exception class in the Exceptions module. The try ... catch construct is used to test and capture exceptions. 2.7 Meta-object protocol Converge implements a simple but powerful Meta-Object Protocol (MOP) [KdRB91], which allows objects to control all behaviour relating to slots. The default MOP is contained within the Object class and comprises the get slot, get slots, has slot and set slot functions. These can be arbitrarily overridden to control which slots the object claims it has (or has not), and what values such slots contain. Note that all accesses go through these functions; if they are overridden in a subclass, the user must exercise caution to call the ‘master’ MOP functions in the Object class to prevent infinite loops. The following example shows a MOP which returns a default value of null for unknown slot names: class M: func get_slot(n): if not self.has_slot(n): return null return exbi Object.get_slot(n) 2.8 Differences from Python Converge deliberately presents a feature set which can be used in a highly similar fashion to Python. Programmers used to Python can easily use Converge in a Python-esque fashion although they will miss out on some of Converge’s more advanced features. The chief differences from Python are that Converge is a more static language, able to make stronger guarantees about namespaces, and that Converge is an expression based language rather than Python’s statement based approach. Converge has a more uniform object system, and less reliance on a battery of globally available builtin functions than Python. One small change from Python to Converge is a generalization of the somewhat confusingly named finally branch which can be attached to Python’s for and while loops. The finally branch is executed if the loop construct terminates naturally (i.e. break is not called). Converge renames the finally branch to exhausted and also allows a broken branch to be added which will be called if a break is encountered. A slightly contrived example of this feature is as follows: high := 10000 for x := fib(high): if x % 9 == 0: break exhausted: Sys.println("No Fibonacci numbers wholly divisible by 9 upto ", high) broken: Sys.println("Fibonacci number ", x, " wholly divisible by 9") 12 2.9 Differences from Icon Converge’s expression system is highly similar to Icon. Provided they can adjust to the Python-esque veneer, Icon programmers will have little difficulty exploiting Converge’s expression system and implementation of goal-directed evaluation. There are however two significant differences in Converge’s functions and generators. Firstly, whereas Icon functions which do not have a return expression at the end of a function have an implicit return fail added, Converge functions instead default to return null (as do Python functions). Icon takes its approach so that generators do not accidentally return an extra object when they should instead fail, and Converge originally took the same approach as Icon. However in practise it is quite common, when developing code, to write incomplete functions — often one part of the code not initially filled in is the functions final return expression. Such functions then cause seemingly bizarre errors since they do not return a value, causing assignments in calling functions to fail and so on (indeed, this happened surprisingly frequently in the early stages of Converge development). Since the proportion of generators to normal functions is small, it seems more sensible to optimise the safety of normal functions at the expense of the safety of generators. As can be seen from section 2.3, generators in Converge generally have return fail as their final action in order to emulate Icon’s behaviour. Secondly, Converge does not propagate generation across a return expression. In Icon, if f is a generator then return f() turns the function containing the return expression into a generator itself which produces all the values that f produces. Converge does not emulate this behaviour, which somewhat arbitrarily turns return into a sort of for construct in certain situations that can only be determined by knowing whether the expression contains a generator. The same behaviour can be obtained in Converge via the following idiom: for yield f() return fail 2.10 Implementation The current Converge implementation consists of a Virtual Machine (VM) written in C, and a compiler written in Converge itself (the current compiler was bootstrapped several generations ago from a much simpler Python version). The VM has a simplistic semi-conservative garbage collector which frees the user from memory management concerns. The VM uses a continuation passing technique at the C level to make the implementation of goal-directed evaluation reasonably simple and transparent from the point of view of extension modules. Its instruction set is largely based on Icon’s, although the VM implementation itself shares more in common with modern VM’s such as Python’s. This paper is not overly concerned with the implementation of the VM and compiler. Interested readers are encouraged to visit http://convergepl.org/ where the VM and compiler can be downloaded and inspected. 13 2.11 Parsing An aspect of Converge and its implementation that is particularly important throughout this paper is its ability to easily parse text. Converge implements a parser toolkit (the Converge Parser Kit or CPK) which contains a parsing algorithm based on that presented by Earley [Ear70]. Earley’s parsing algorithm is interesting since it accepts and parses any Context Free Grammar (CFG) — this means that grammars do not need to be written in a restricted form to suit the parsing algorithm, as is the case with traditional parsing algorithms such as LALR. Practical implementations of Earley parsers have traditionally been scarce, since the flexibility of the algorithm results in slower parsing times than traditional parsing algorithms. The CPK utilises some (though not all) of the additional techniques developed by Aycock and Horspool [AH02] to improve its parsing time, particularly those relating to the ε production. Even though the CPK contains an inefficient implementation of the algorithm, on a modern machine, and even with a complex grammar, it is capable of parsing in the low hundreds of lines per second which is sufficient for the purposes of this paper. The performance of more sophisticated Earley parsers such as Accent [Sch] suggest that the CPK’s performance could be raised by approximately an order of magnitude with relatively little effort. Parsing in Converge is preceded by a tokenization (also known as lexing) phase. The CPK provides no special support for tokenization, since the built-in regular expression library makes the creation of custom tokenizers trivial. Tokenizers are expected to return a list of objects, each of which has slots type, value, src file and src offset. The first two slots represent the type (i.e. ID) and value (i.e. height) of a token and must be strings; the latter two slots record both the file and character offset within the file that a particular token originated in. The tokenizer for Converge itself is somewhat unusual in that it needs to understand about indentation in order that the grammar can be expressed satisfactorily. Essentially each increase in the level of indentation results in a INDENT token being generated; each decrease results in a DEDENT followed by a NEWLINE token. Each newline on the same level of indentation results in a NEWLINE token. The CPK implements an EBNF style system – essentially a BNF system with the addition of the Kleene star. CPK production rules consist of a rule name, and one or more alternatives. Each alternative consists of tokens, references to other rules and groupings. Currently the only form of grouping accepted is the Kleene star. The CPK grammar itself is as follows: hgrammari ::= hrulei* hrulei ::= ‘ID’ hrule alternativei* hrule alternativei ::= ‘::=’ hrule elemi* | ‘::=’ hrule elemi* ‘%PRECEDENCE’ ‘INT’ hrule elemi ::= hatomi | hgroupingi 14 hgroupingi ::= ‘{’ hatomi ‘}*’ hatomi ::= ‘"’ ‘TOKEN’ ‘"’ | ‘ID’ Since Earley grammars can express any CFG, grammars can be ambiguous — that is, given inputs can satisfy the grammar in more than one way. In order to disambiguate between alternatives when building the parse tree, the CPK allows grammar rules to have a precedence attached to them; if more than one rule has been used to parse a given group of tokens, the rule with the highest precedence is used 3 . In order to use the CPK, the user must provide it with a grammar, the name of a start rule within the grammar, and a sequence of tokens. The result of a CPK parse is an automatically constructed parse tree, which is represented as a nested Converge list of the form [production name, token or list1 , ..., token or listn ]. The following program fragment shows a CPK grammar for a simple calculator: GRAMMAR := """ S ::= E E ::= E "+" E %precedence 10 ::= E "*" E %precedence 30 ::= "(" E ")" ::= N "INT" %precedence 10 N ::= "-" ::= """ Assuming the existence of a suitable tokenize function, an example program which uses this grammar to parse input is as follows: import CPK.Grammar, CPK.Parser func calc_parse(input): grammar := Grammar.Grammar(GRAMMAR, "S") tokens := tokenize(input) parser := Parser.Parser(grammar) tree := parser.parse(tokens) Sys.println(tree) The parse tree is printed out as: ["S", ["E", ["E", ["N"], <INT 5>], <+>, ["E", ["E", ["N"], <INT 2>], <*>, ["E", ["N"], <INT 3>]]]] This is somewhat easier to visualize when using the parse tree function in a Parser instance to format the list as a tree: S-> E -> E -> N -> INT <5> + E -> E -> 3 Note that there is another, much rarer, type of ambiguity involving alternatives which contain different number of tokens. These are currently always resolved in favour of the alternative containing the least number of tokens, no matter its precedence. This generally gives the expected behaviour, but can cause problems in some rare cases. This limitation is purely due to a naı̈ve implementation. 15 N -> INT <2> * E -> N -> INT <3> The full Converge grammar can be seen in appendix A. 2.12 Related work This section has made several comparisons between Converge, and Icon and Python in particular. These are not repeated in this subsection. The Unicon project [JMPP03] is in the reasonably advanced stages of extending Icon with object orientated features. It differs significantly from Converge in maintaining virtually 100% compatibility with Icon. Unicon’s extensions to Icon, effectively being a bolt-on to the original, mean the resulting language contains more visible seams than does Converge. Godiva Godiva [Jef02], which aims to be a ‘very high level dialect of Java’ incorporating goal-directed evaluation. In reality, Godiva’s claim to be a dialect of Java is slightly tenuous: whilst it shares some syntax, the semantics are substantially different. Neither Unicon nor Godiva are meta-circular, and both are less dynamic languages than Converge. 3 Compile-time meta-programming 3.1 Background Compile-time meta-programming allows the user of a programming language a mechanism to interact with the compiler to allow the construction of arbitrary program fragments by user code. As Steele argues, ‘a main goal in designing a language should be to plan for growth’ [Ste99] – compile-time meta-programming is a powerful mechanism for allowing a language to be grown in ways limited only by a users imagination. Compile-time meta-programming allows users to e.g. add new features to a language [SeABP99] or apply application specific optimizations [SCK03]. The LISP family of languages, such as Scheme [KCR98], have long had powerful macro facilities allowing program fragments to be built up at compile-time. Such macro schemes suffered for many years from the problem of variable capture; fortunately modern implementations of hygienic macros [DHB92] allow macros to be used safely. LISP and Scheme programs make frequent use of macros, which are an integral and vital feature of the language. Compile-time meta-programming is, at first glance, just a new name for an old concept – macros. However, LISP-esque macros are but one way of realizing compile-time meta-programming. Brabrand and Schwartzbach differentiate between two main categories of macros [BS00]: those which operate at the syntactic level and those which operate at the lexing level. Scheme’s macro system works at the syntactic level: it operates on Abstract 16 Syntax Trees (AST’s), which structure a programs representation in a way that facilitates making sophisticated decisions based on a nodes context within the tree. Macro systems operating at the lexing level are inherently less powerful, since they essentially operate on a text string, and have little to no sense of context. Despite this, of the relatively few mainstream programming languages which have macro systems, by far the most widely used is the C preprocessor (CPP), a lexing system which is well-known for causing bizarre programming headaches due to unexpected side effects of its use (see e.g. [CMA93, Baw99, EBN02]). Despite the power of syntactic macro systems, and the wide-spread usage of the CPP, relatively few programming languages other than LISP and C explicitly incorporate such systems (of course, a lexing system such as the CPP can be used with other text files that share the same lexing rules). One of the reasons for the lack of macro systems in programming languages is that whilst lexing systems are recognised as being inadequate, modern languages do not share LISP’s syntactic minimalism. This creates a significant barrier to creating a system which matches LISP’s power and seamless integration with the host language [BP99]. Relatively recently languages such as the multi-staged MetaML [Tah99] and Template Haskell (TH) [SJ02] have shown that statically typed functional languages can house powerful compile-time meta-programming facilities where the run-time and compiletime languages are one and the same. Whereas lexing macro systems typically introduce an entirely new language to proceedings, and LISP macro systems need the compiler to recognise that macro definitions are different from normal functions, languages such as TH move the macro burden from the point of definition to the macro call point. In so doing, macros suddenly become as any other function within the host language, making this form of compile-time meta-programming in some way distinct from more traditional macro systems. Importantly these languages also provide powerful, but usable, ways of coping with the syntactic richness of modern languages. Most of the languages which fall into this new category of compile-time metaprogramming languages are statically typed functional languages. In this section I detail an extension to the core Converge language which adds compile-time metaprogramming facilities similar to TH. Since this is the first time that facilities of this nature have been added to a dynamically-typed OO language such as Converge, section 4 details the implications of adding such a feature to similar languages. 3.2 A first example The following program is a simple example of compile-time meta-programming, trivially adopted from it’s TH cousin in [CJOT04]. expand power recursively creates an expression that multiplies n x times; mk power takes a parameter n and creates a function that takes a single argument x and calculates xn ; power3 is a specific power function which calculates n3 : func expand_power(n, x): if n == 0: return [| 1 |] else: 17 return [| $<<x>> * $<<expand_power(n - 1, x)>> |] func mk_power(n): return [| func (x): return $<<expand_power(n, [| x |])>> |] power3 := $<<mk_power(3)>> The user interface to compile-time meta-programming is inherited fairly directly from TH: quasi-quote expressions [| ... |] build abstract syntax trees - ITree’s in Converge’s terminology - that represent the program code contained within them, and the splice annotation $<<...>> evaluates its expression at compile-time (and before VM instruction generation), replacing the splice annotation itself with the ITree resulting from its evaluation. When the above example has been compiled into VM instructions, power3 essentially looks as follows: power3 := func (x): return x * x * x * 1 By using the quasi-quotes and splicing mechanisms, we have been able to synthesise at compile-time a function which can efficiently calculate powers without resorting to recursion, or even iteration. Note how apart from the quasi-quotes and splicing mechanisms no extra features have been added to the base language – unlike LISP style languages, all parts of a Converge program are first-class elements regardless of whether they are executed at compile-time or run-time. This terse explanation hides much of the necessary detail which can allow readers who are unfamiliar with similar systems to make sense of this synthesis. In the following sections, I explore the interface to compile-time meta-programming in more detail, building up the picture step by step. 3.3 Splicing The key part of the ‘powers’ program is the splice annotation in the line power3 := $<<mk power(3)>>. The top-level splice tells the compiler to evaluate the expression between the chevrons at compile-time, and to include the result of that evaluation in the module for ultimate bytecode generation. In order to perform this evaluation, the compiler creates a temporary or ‘dummy’ module which contains all definitions up to, but excluding, the definition the splice annotation is a part of; to this temporary module a new splice function (conventionally called $$splice$$) is added which contains a single expression return splice expr. This temporary module is compiled to bytecode and injected into the running VM, whereupon the splice function is called. Thus the splice function ‘sees’ all the definitions prior to it in the module, and can call them freely – there are no other limits on the splice expression. The splice function must return a valid ITree which the compiler uses in place of the splice annotation. Evaluating a splice expression leads to a new ‘stage’ in the compiler being executed. Converge’s rules about which references can cross the staging boundary are simple: only references to top-level module definitions can be carried across the staging boundary (see section 3.5). For example the following code is invalid since the 18 variable x will only have a value at run-time, and hence is unavailable to the splice expression which is evaluated at compile-time: func f(x): $<<g(x)>> Although the implementation of splicing in Converge is more flexible than in TH – where splice expressions can only refer to definitions in imported modules – it raises a new issue regarding forward references. This is tackled in section 3.9. Note that splice annotations within a file are executed strictly in order from top to bottom, and that splice annotations can not contain splice annotations. 3.3.1 Permissible splice locations Converge is more flexible than TH in where it allows splice annotations. A representative sample of permissible locations is: Top-level definitions. Splice annotations in place of top-level definitions must return an ITree, or a list of ITree’s, each of which must be an assignment. Function names. Splice annotations in place of function names must return a Name (see section 3.6.2). Expressions. Splice annotations as expressions can return any normal ITree. A simple example is $<<x>> + 2. We saw another example in the ‘powers’ program with power3 := $<<mk power(3)>>. Within a block body. Splice annotations in block bodies (e.g. a functions body) accept either a single ITree, or a list of ITree’s. Lists of ITree’s will be spliced in as if they were expressions separated by newlines. A contrived example that shows the last three of these splice locations (in order) in one piece of code is as follows: func $<<create_a_name()>>(): x := $<<f()>> + g() $<<list_of_exprs()>> At compile-time, this will result in a function named by the result of create a name and containing 1 or more expressions, depending on the number of expressions returned in the list by list of exprs. Note that the splice expressions must return a valid ITree for the location of a splice annotation. For example, attempting to splice in a sequence of expressions into an expression splice such as $<<x>> + 2 results in a compile-time error. 3.4 The quasi-quotes mechanism In the previous section we saw that splice annotations are replaced by ITree’s. In many systems the only way to create ITree’s is to use a verbose and tedious interface of ITree creating functions which results in a ‘style of code [which] plagues meta-programming systems’ [WC93]. LISP’s quasi-quote mechanism allows programmers to build up 19 LISP S-expressions (which, for our purposes, are analogous to be ITree’s) by writing normal code prepended by the backquote ‘ notation; the resulting S-expression can be easily manipulated by a LISP program. Unfortunately LISP’s syntactic minimalism is unrepresentative of modern languages, whose rich syntaxes are not as easily represented and manipulated. MetaML and, later TH, introduce a quasi-quotes mechanism suited to syntactically rich languages. Converge inherits TH’s Oxford quotes notation [| ...|] notation to represent a quasi-quoted piece of code. Essentially a quasi-quoted expression evaluates to the ITree which represents the expression inside it. For example, whilst the raw Converge expression 4 + 2 prints 6 when evaluated, [| 4 + 2 |] evaluates to an ITree which prints out as 4 + 2. Thus the quasi-quote mechanism constructs an ITree directly from the users input - the exact nature of the ITree is of immaterial to the casual ITree user, who need not know that the resulting ITree is structured along the lines of add(int(4), int(2)). To match the fact that splice annotations in blocks can accept sequences of expressions to splice in, the quasi-quotes mechanism allows multiple expressions to be expressed within it, split over newlines. The result of evaluating such an expression is, unsurprisingly, a list of ITree’s. Note that as in TH, Converge’s splicing and quasi-quote mechanisms cancel each other out: $<<[| x |]>> is equivalent to x (though not necessarily vice versa). 3.4.1 Splicing within quasi-quotes In the ‘powers’ program, we saw the splice annotation being used within quasi-quotes. The explanation of splicing in section 3.3 would seem to suggest that the splice inside the quasi-quoted expression in the expand power function should lead to a staging error since it refers to variables n and x which were defined outside of the splice annotation. In fact, splices within quasi-quotes work rather differently to splices outside quasi-quotes: most significantly the splice expression itself is not evaluated at compile-time. Instead the splice expression is essentially copied as-is into the code that the quasi-quotes transforms to. For example, the quasi-quoted expression [| $<<x>> + 2 |] leads to an ITree along the lines of add(x, int(2)) – the variable x in this case would need to contain a valid ITree. As this example shows, since splice annotations within quasi-quotes are executed at run-time they can access variables without staging concerns. This feature completes the cancelling out relationship between splicing and quasiquoting: [| $<<x>> |] is equivalent to x (though not necessarily vice versa). 3.5 Basic scoping rules in the presence of quasi-quotes The quasi-quote mechanism can be used to surround any Converge expression to allow the easy construction of ITree’s. Quasi-quoting an expression also has another important feature: it fully respects lexical scoping. Take the following contrived example of module A: 20 func x(): return 4 func y(): return [| x() * 2 |] and module B: import A, Sys func x(): return 2 func main(): Sys.println($<<A.y()>>) The quasi-quotes mechanisms ensures that since the reference to x in the quasi-quoted expression in A.y refers lexically to A.x, that running module B prints out 8. This example shows one of the reasons why Converge needs to be able to statically determine namespaces: since the reference of x in A.y is lexically resolved to the function A.x, the quasi-quotes mechanism can replace the simple reference with an original name 4 that always evaluates to the slot x within the specific module A wherever it is spliced into, even if A is not in scope (or a different A is in scope) in the splice location. Some other aspects of scoping and quasi-quoting require a more subtle approach. Consider the following (again contrived) example: func f(): return [| x := 4 |] func g(): x := 10 $<<f()>> y := x What might one expect the value of y in function g to be after the value of x is assigned to it? A naı̈ve splicing of f() into g would mean that the x within [| x := 4 |] would be captured by the x already in g – y would end with the value 4. If this was the case, using the quasi-quote mechanism could potentially cause all sorts of unexpected interactions and problems. This problem of variable capture is well known in the LISP community, and hampered LISP macro implementations for many years until the concept of hygienic macros was invented [KFFD86]. A new subtlety is now uncovered: not only is Converge able to statically determine namespaces, but variable names can be α-renamed without affecting the programs semantics. This is a significant deviation from the Python heritage. The quasi-quotes mechanism determines all bound variables in a quasi-quoted expression, and preemptively α-renames each bound variable to a guaranteed unique name that the user can not specify; all references to the variable are updated similarly. Thus the x within [| x := 4 |] will not cause variable capture to occur, and the variable y in function g will be set to 10. There is one potential catch: top-level definitions (all of which are assignments to a variable, although syntactic sugar generally obscures this fact) can not be α-renamed without affecting the programs semantics. This is because Converge’s dynamic typing means that referencing a slot within a module can not in all cases be statically checked at runtime. Thus renaming top-level definitions could lead to run-time ‘slot missing’ exceptions being raised. Although the current compiler does not catch this case, since 4 This terminology is borrowed from TH, but with a much different implementation. 21 the user is unlikely to have cause to quasi-quote top-level definitions, barring it should be of little practical consequence. Whilst the above rules explain the most important of Converge’s scoping rules in the presence of quasi-quotes, upcoming sections add extra detail to the basic scoping rules explained in this section. 3.6 The CEI interface At various points when compile-time meta-programming, one needs to interact with the Converge compiler. The Converge compiler is entirely contained within a package called Compiler which is available to every Converge program. The CEI module within the Compiler package is the officially sanctioned interface to the Compiler, and can be imported with import Compiler.CEI. 3.6.1 ITree functions Although the quasi-quotes mechanism allows the easy, and safe, creation of many required ITree’s, there are certain legal ITree’s which it can not express. Most such cases come under the heading of ‘create an arbitrary number of X’ e.g. a function with an arbitrary number of parameters, or an if expression with an arbitrary number of elif clauses. In such cases the CEI interface presents a more traditional meta-programming interface to the user that allows ITree’s that are not expressible via quasi-quotes to be built. The downside to this approach is that recourse to the manual is virtually guaranteed: the user needs to know the name of the ITree element(s) required (each element has a corresponding function with a lower case name and a prepended ‘i’ in the CEI interface e.g. ivar), what the functions requirements are etc. Fortunately this interface needs to be used relatively infrequently; all uses of it are explained explicitly in this paper. 3.6.2 Names Section 3.3 showed that the Converge compiler sometimes uses names for variables that the user can not specify using concrete syntax. The same technique is used by the quasi-quote mechanism to α-rename variables to ensure that variable capture does not occur. However one of the by-products of the arbitrary ITree creating interface provided by the CEI interface is that the user is not constrained by Converge’s concrete syntax; potentially they could create variable names which would clash with the ‘safe’ names used by the compiler. To ensure this does not occur, the CEI interface contains several functions – similar to those in recent versions of TH – related to names which the user is forced to use; these functions guarantee that there can be no inadvertent clashes between names used by the compiler and by the user. In order to do this, the CEI interface deals in terms of instances of the CEI.Name class. In order to create a variable, a slot reference etc, the user must pass an instance of this class to the relevant function in the CEI interface. New names can be created 22 by one of two functions. The name(x) function validates x, raising an exception if it is invalid, and returning a Name otherwise. The fresh name function guarantees to create a unique Name each time it is called (this is the interface used by the quasiquotes mechanism). This allows e.g. variable names to be created safely with the idiom var := CEI.ivar(CEI.name("var name")). fresh name takes an optional argument x which, if present, is incorporated into the generated name whilst still guaranteeing the uniqueness of the resulting name; this feature aids debugging by allowing the user to trace the origins of a fresh name. Note that the name interface opens the door for dynamic scoping (see section 3.8). 3.7 Lifting values When meta-programming, one often needs to take a normal Converge value (e.g. a string) and obtain its ITree equivalent: this is known as lifting a value. Consider a debugging function log which prints out the debug string passed to it; this function is called at compile-time so that if the global DEBUG BUILD variable is set to fail there is no run-time penalty for using its facility. The log function is thus a safe means of performing what is often termed ‘conditional compilation’. Noting that pass is the Converge no-op, a first attempt at such a function is as follows: func log(msg): if DEBUG_BUILD: return [| Sys.println(msg) |] else: return [| pass |] This function fails to compile: the reference to the msg variable causes the Converge compiler to raise the error Var ‘msg’ is not in scope when in quasi-quotes (consider using $<<CEI.lift(msg)>>. Rewriting the offending piece of code to the following gives the correct solution: return [| Sys.println($<<CEI.lift(x)>>) |] What has happened here is that the string value of x is transformed by the lift function into its abstract syntax equivalent. Constants are automatically lifted by the quasiquotes mechanism: the two expressions [| $<<CEI.lift("str")>> |] and [| "str" |] are therefore equivalent. Converge’s refusal to lift the raw reference to msg in the original definition of log is a significant difference from TH, whose scoping rules would have caused msg to be lifted without an explicit call to CEI.lift. To explain this difference, assume the log function is rewritten to include the following fragment: return [| msg := "Debug: " + $<<CEI.lift(msg)>> Sys.println(msg) |] In a sense, the quasi-quotes mechanism can be considered to introduce its own block: the assignment to the msg variable forces it to be local to the quasi-quote block. This needs to be the case since the alternative behaviour is nonsensical: if the assignment referenced to the msg variable outside the quasi-quotes then what would the effect 23 of splicing in the quasi-quoted expression to a different context be? The implication of this is that referencing a variable within quasi-quotes would have a significantly different meaning if the variable had been assigned to within the quasi-quotes or outside it. Whilst it is easy for the Converge compiler writer to determine that a given variable was defined outside the quasi-quotes and should be automatically lifted in (or vice versa), from a user perspective the behaviour can be unnecessarily confusing. In fact Converge’s quasi-quote mechanism originally did automatically lift variable references when possible, but this feature proved confusing in practise. To avoid this, Converge forces variables defined outside of quasi-quotes to be explicitly lifted into it. This also maintains a simple symmetry with Converge’s main scoping rules: assigning to a variable in a block makes it local to that block. 3.8 Dynamic scoping Sometimes the quasi-quote mechanisms automatic α-renaming of variables is not what is needed. For example consider a function swap(x, y) which should swap the values of the two variables passed as strings in its parameters. In such a case, we want the result of the splice to capture the variables in the spliced environment. Because the quasi-quotes mechanism only renames variables which it can determine statically at compile time, any variables created via the idiom CEI.ivar(CEI.name(x)) and spliced into the quasi-quotes will not be renamed. The following succinct definition of swap takes advantage of this fact: func swap(x, y): x_var := CEI.ivar(CEI.name(x)) y_var := CEI.ivar(CEI.name(y)) return [| temp := $<<x_var>> $<<x_var>> := $<<y_var>> $<<y_var>> := temp |] Note that the variable temp within the quasi-quotes will be α-renamed and thus will be effectively invisible to the code that it is spliced into, but that the two variables referred to by x and y will be scoped by their splice location. This function can be used thus: a := 10 b := 20 $<<swap("a", "b")>> Dynamic scoping also tends to be useful when a quasi-quoted function is created piecemeal with many separate quasi-quote expressions. In such a case, variable references can only be resolved successfully when all the resulting ITree’s are spliced together since references to the functions parameters and so on will not be determined until that point. Since it is highly tedious to continually write CEI.ivar(CEI.name("foo")), Converge provides the special syntax &foo which is equivalent. 3.9 Forward references and splicing In section 3.3 we saw that when a splice annotation outside quasi-quotes is encountered, a temporary module is created which contains all the definitions up to, but ex24 cluding, the definition holding the splice annotation. This is a very useful feature since compile-time functions used only in one module can be kept in that module. However this introduces a real problem involving forward references. A forward reference is defined to be a reference to a definition within a module, where the reference occurs at an earlier point in the source file than the definition. If a splice annotation is encountered and compiles a subset of the module, then some definitions involved in forward references may not be included: thus the temporary module will fail to compile, leading to the entire module not compiling. Worse still, the user is likely to be presented with a highly confusing error telling them that a particular reference is undefined when, as far as they are concerned, the definition is staring at them within their text editor! Consider the following contrived example: func f1(): return [| 7 |] func f2(): x := f4() func f3(): return $<<f1()>> func f4(): pass If f2 is included in the temporary module created when evaluating the splice annotation in f3, then the forward reference to f4 will be unresolvable. The solution taken by Converge ensures that, by including only a minimal subset of definitions in the temporary module, most forward references do not raise a compiletime error. We saw in section 3.5 that the quasi-quotes mechanism uses Converge’s statically determined namespaces to calculate bound variables. That same property is now used to determine an expressions free variables. When a splice annotation is encountered, the Converge compiler does not immediately create a temporary module. First it calculates the splice expressions free variables; any previously encountered definition which has a name in the set of free variables is added to a set of definitions to include. These definitions themselves then have their free variables calculated, and again any previously encountered definition which has a name in the set of free variables is added to the set of definitions to include. This last step is repeated until an iteration adds no new definitions to the set. At this point, Converge then goes back in order over all previously encountered definitions, and if the definition is in the list of definitions to include, it is added to the temporary module. Recall that the order of definitions in a Converge file can be significant (see section 2.4): this last stage ensures that definitions are not reordered in the temporary module. Note also that free variables which genuinely do not refer to any definitions (i.e. a mistake on the part of the programmer) will pass through this scheme unmolested and will raise an appropriate error when the temporary module is compiled. Using this method, the temporary module that is created and evaluated for the example looks as follows: func f1(): return [| 7 |] func $$splice$$(): return f1() There are thus no unresolvable forward references in this example. 25 There is a secondary, but significant, advantage to this method: since it reduces the number of definitions in temporary modules it can lead to an appreciable saving in compile time, especially in files containing multiple splice annotations. 3.10 Compile-time meta-programming in use In this paper thus far we have seen several uses of compile-time meta-programming. There are many potential uses for this feature, many of which are too involved to detail in the available space. For example, one of the most exciting uses of the feature has been in conjunction with Converge’s extendable syntax feature (see section 5), allowing powerful DSL’s to be expressed in an arbitrary concrete syntax. One can see similar work involving DSL’s in e.g. [SCK03, CJOT04]. In this section I show two seemingly mundane uses of compile-time meta-programming: conditional compilation and compile-time optimization. Although mundane in some senses, both examples open up potential avenues not currently available to other dynamically typed OO languages. 3.10.1 Conditional compilation Whereas languages such as Java attempt to insulate their users from the underlying platform an application is running on, languages such as Python and Ruby allow the user access to many of the lower-level features the platform provides. Many applications rely on such low-level features being available in some fashion. However for the developer who has to provide access to such features a significant problem arises: how does one sensibly provide access to such features when they are available, and to remove that access when they are unavailable? The log function on page 23 was a small example of conditional compilation. Let us consider a simple but realistic example that is more interesting from an OO perspective. The POSIX fcntl (File CoNTrol) feature provides low-level control of file descriptors, for example allowing file reads and writes to be set to be non-blocking; it is generally only available on UNIX-like platforms. Assume that we wish to provide some access to the fcntl feature via a method within file objects; this method will need to call the raw function within the provided fcntl module iff that module is available on the current platform. In Python for example, there are two chief ways of doing this. The first mechanism is for a File class to defer checking for the existence of the fcntl module until the fcntl method is called, raising an exception if the feature is not detected in the underlying platform. Callers who wish to avoid use of the fcntl method on platforms lacking this feature must use catch the appropriate exception. This rather heavy handed solution goes against the spirit of duck typing [TH00], a practise prevalent in languages such as Ruby and Python. In duck typing, one essentially checks for the presence of a method(s) which appear to satisfy a particular API without worrying about the type of the object in question. Whilst this is perhaps unappealing from a theoretical point of view, this approach is common in practise due to the low-cost flexibility it leads to. 26 To ensure that duck typing is possible in our fcntl example, we are forced to use exception handling and the dynamic selection of an appropriate sub-class: try: import fcntl _HAVE_FCNTL = True except exceptions.ImportError: _HAVE_FCNTL = False class Core_File: # ... if _HAVE_FCNTL: class File(Core_File): def fcntl(op, arg): return fcntl.fcntl(self.fileno(), op, arg) else: class File(Core_File): pass Whilst this allows for duck typing, this idiom is far from elegant. The splitting of the File class into a core component and sub-classes to cope with the presence of the fcntl functionality is somewhat distasteful. This example is also far from scalable: if one wishes to use the same approach for more features in the same class then the resultant code is likely to be highly fragile and complex. Although it appears that the above idiom can be encoded largely ‘as is’ in Converge, we immediately hit a problem due to the fact that module imports are statically determined. Thus a direct Converge analogue would compile correctly only on platforms with a fcntl module. However by using compile-time meta-programming one can create an equivalent which functions correctly on all platforms and which cuts out the ugly dynamic sub-class selection. The core feature here is that class fields are permissible splice locations (see section 3.3.1). A splice which returns an ITree that is a function will have that function incorporated into the class; if the splice returns pass as an ITree then the class is unaffected. So at compile-time we first detect for the presence of a fcntl module (the VM.loaded module names function returns a list containing the names of all loaded modules); if it is detected, we splice in an appropriate fcntl method otherwise we splice in the no-op. This example make use of two hitherto unencountered features. Firstly, using an if construct as an expression requires a different syntax (to work around parsing limitations associated with indentation based grammars); the construct evaluates to the value of the final expression in whichever branch is taken, failing if no branch is taken. Secondly the modified Oxford quotes [d| ...|] – declaration quasi-quotes – act like normal quasi-quotes except they do not α-rename variables; declaration quotes are typically most useful at the top-level of a module. The Converge example is as follows: $<<if VM.loaded_module_names().contains("FCntl") { [d| import FCntl _HAVE_FCNTL := 1 |] } else { [d| _HAVE_FCNTL := 0 |] }>> 27 class File: $<<if _HAVE_FCNTL { [| func fcntl(op, arg): return FCntl.fcntl(self.fileno(), op, arg) |] } else { [| pass |] }>> Although this example is simplistic in many ways, it shows that compile-time metaprogramming can provide a conceptually neater solution than any purely run-time alternative since it allows related code fragments to be kept together. It also provides a potential solution to related problems. For example portability related code in dynamically typed OO languages often consists of many if statements which perform different actions depending on a condition which relates to querying the platform in use. Such code can become a performance bottleneck if called frequently within a program. The use of compile-time meta-programming can lead to a zero-cost run-time overhead. Perhaps significantly, the ability to tune a program at compile-time for portability purposes is the largest single use of the C preprocessor [EBN02] – compile-time meta-programming of the sort found in Converge not only opens similar doors for dynamically typed OO languages, but allows the process to occur in a far safer, more consistent and more powerful environment than the C preprocessor. 3.11 Run-time efficiency In this section I present the Converge equivalent of the TH compile-time printf function given in [SJ02]. Such a function takes a format string such as "%s has %d %s" and returns a quasi-quoted function which takes an argument per ‘%’ specifier and intermingles that argument with the main text string. For our purposes, we deal with decimal numbers %d and strings %s. The motivation for a TH printf is that such a function is not expressible in base Haskell. Although Converge functions can take a variable number of arguments (as Python, but unlike Haskell), having a compile-time version still has two benefits over its run-time version: any errors in the format string are caught at compile-time; an efficiency boost. This example assumes the existence of a function split format which given a string such as "%s has %d %s" returns a list of the form [PRINTF STRING, " has ", PRINTF INT, " ", PRINTF STRING] where PRINTF STRING and PRINTF INT are constants. First we define the main printf function which creates the appropriate number of parameters for the format string (of the form p0, p1 etc.). Parameters must be created by the CEI interface. An iparam has two components: a variable, and a default value (the latter can be set to null to signify the parameter is mandatory and has no default value). printf then returns an anonymous quasi-quoted function which contains the parameters, and a spliced-in expression returned by printf expr: 28 func printf(format): split := split_format(format) params := [] i := 0 for part := split.iterate(): if part == PRINTF_INT | part == PRINTF_STRING: params.append(CEI.iparam(CEI.ivar(CEI.name("p" + i.to_str())), null)) i += 1 return [| func ($<<params>>): Sys.println($<<printf_expr(split, 0)>>) |] printf expr is a recursive function which takes two parameters: a list representing the parts of the format string yet to be processed; an integer which signifies which parameter of the quasi-quoted function has been reached. func printf_expr(split, param_i): if split.len() == 0: return [| "" |] param := CEI.ivar(CEI.name("p" + param_i.to_str())) if split[0].conforms_to(String): return [| $<<CEI.lift(split[0])>> + $<<printf_expr(split[1 : ], param_i)>> |] elif split[0] == PRINTF_INT: return [| $<<param>>.to_str() + $<<printf_expr(split[1 : ], param_i + 1)>> |] elif split[0] == PRINTF_STRING: return [| $<<param>> + $<<printf_expr(split[1 : ], param_i + 1)>> |] Essentially, printf expr recursively calls itself, each time removing the first element from the format string list, and incrementing the param i variable iff a parameter has been processed. This latter condition is invoked when a string or integer ‘%’ specifier is encountered; raw text in the input is included as is, and as it does not involve any of the functions parameters, does not increment param i. When the format string list is empty, the recursion starts to unwind. When the result of printf expr is spliced into the quasi-quoted function, the dynamically scoped references to parameter names in printf expr become bound to the quasi-quoted functions’ parameters. As an example of calling this function, $<<printf("%s has %d %s")>> generates the following function: func (p0, p1, p2): Sys.println(p0 + " has " + p1.to_str() + " " + p2 + "") so that evaluating the following: $<<printf("%s has %d %s")>>("England", 39, "traditional counties") results in England has 39 traditional counties being printed to screen. This definition of printf is simplistic and lacks error reporting, partly because it is intended to be written in a similar spirit to its TH equivalent. Converge comes with a more complete compile-time printf function as an example, which uses an iterative solution with more compile-time and run-time error-checking. Simple benchmarking of the latter function reveals that it runs nearly an order of magnitude faster than its run-time equivalent5 – a potentially significant gain when a tight loop repeatedly calls printf. 5 This large differential is in part due to the fact that the current Converge VM imposes a relatively high overhead on function application. 29 3.12 Compile-time meta-programming costs Although compile-time meta-programming has a number of benefits, it would be naı̈ve to assume that it has no costs associated with it. However although Converge’s features have been used to build several small programs, and two systems of several thousand lines of code each, it will require a wider range of experience from multiple people working in different domains to make truly informed comments in this area. One thing is clear from experience with LISP: compile-time meta-programming in its rawest form is not likely to be grasped by every potential developer [Que96]. To use it to its fullest potential requires a deeper understanding of the host language than many developers are traditionally used to; indeed, it is quite possible that it requires a greater degree of understanding than many developers are prepared to learn. Whilst features such as extendable syntax (see section 5) which are layered on top of compile-time meta-programming may smooth off many of the usability rough edges, fundamentally the power that compile-time meta-programming extends to the user comes at the cost of increased time to learn and master. In Converge one issue that arises is that code which continually dips in and out of the meta-programming constructs can become rather messy and difficult to read on screen if over-used in any one area of code. This is due in no small part to the syntactic considerations that necessitate a move away from the clean Python-esque syntax to something closer to the C family of languages. It is possible that the integration of similar features into other languages with a C-like syntax would lead to less obvious syntactic seams. 3.13 Error reporting Perhaps the most significant unresolved issue in compile-time meta-programming systems relates to error reporting [CJOT04]. Although Converge does not have complete solutions to all issues surrounding error reporting, it does contain some rudimentary features which may give insight into the form of more powerful error reporting features both in Converge and other compile-time meta-programming systems. The first aspect of Converge’s error reporting facilities relates to exceptions. When an exception is raised, detailed stack traces are printed out allowing the user to inspect the sequence of calls that led to the exception being raised. These stack traces differ from those found in e.g. Python in that each level in the stack trace displays the file name, line number and column number that led to the error. Displaying the column number allows users to make use of the fine-grained information to more quickly narrow down the precise source of an exception. Converge is able to display such detailed information because when it parses text, it stores the file name, line number and column number of each token. Tokens are ordered into parse trees; parse trees are converted into ASTs; ASTs are eventually converted into VM instructions. At each point in this conversion, information about the source code elements is retained. Thus every VM instruction in a binary Converge program has a corresponding debugging entry which records which file, line number and column number the VM instruction relates 30 to. Whilst this does require more storage space than simpler forms of error information, the amount of space required is insignificant when the vast storage resources of modern hardware are considered. Whilst the base language needs to record the related source offset of each VM instruction, the source file a VM instruction relates to is required only due to compiletime meta-programming. Consider a file A.cv: func f(): return [| 2 + "3" |] and a file B.cv: import A func main(): $<<A.f()>> When the quasi-quoted code in A.f is spliced in, and then executed an exception will be raised about the attempted addition of an integer and a string. The exception that results from running B is as follows: Traceback (most recent call last): File "A.cv", line 2, column 13, in main Type_Exception: Expected instance of Int, but got instance of String. The fact that the A module is pinpointed as the source of the exception may initially seem surprising, since the code raising the exception will have been spliced into the B module. This is however a deliberate design choice in Converge. Although the code from A.f has been spliced into B.main, when B is run the quasi-quoted code retains the information about its original source file, and not its splice location. To the best of my knowledge, this approach to error reporting in the face of compile-time meta-programming is unique. As points of comparison, TH is not able to produce any detailed information during a stack-trace and SCM Scheme [Jaf03] pinpoints the source file and line number of run-time errors as that of the macro call site. In SCM Scheme if the code that a macro produces contains an error, all the user can work out is which macro would have led to the problem — the user has no way of knowing which part of the macro may be at fault. Converge allows customization of the error-reporting information stored about a given ITree. Firstly Converge adds a feature not present in TH: nested quasi-quotes. Essentially an outer quasi-quote returns the ITree of the code which would create the ITree of the nested quasi-quote. For example the following nested code: Sys.println([| [| 2 + "3" |] |].pp()) results in the following output: CEI.ibinary_add(CEI.iint(2, "ct.cv", 484), CEI.istring("3", "ct.cv", 488), "ct.cv", 486) Nested quasi-quotes provide a facility which allows users to analyse the ITrees that plain quasi-quotes generate: one can see in the above that each ITree element contains a reference to the file it was contained within (ct.cv in this case) and to the offset within the file (484 and so on). The CEI module provides a function src info to var 31 which given an ITree representing quasi-quoted code essentially copies the ITree 6 replacing the source code file and offsets with variables src file and src offset. This new ITree is then embedded in a quasi-quoted function which takes two arguments src file and src offset. When the user splices in, and then calls, this function they update the ITree’s relation to source code files and offsets. Using this function in the following fashion: Sys.println(CEI.src_info_to_var([| [| 2 + "3" |] |]).pp()) results in the following output: unbound_func (src_file, src_offset){ return CEI.ibinary_add(CEI.iint(2, src_file, src_offset), CEI.istring("3", src_file, src_offset), src_file, src_offset) } In practice when one wishes to customise the claimed location of quasi-quoted code, the nested quasi-quotes need to be cancelled out by a splice. For example, to change source information to be offset 77 in the file nt.cv we would use the following code: return $<<CEI.src_info_to_var([| [| 2 + "3" |] |], "nt.cv", 77>> Whilst this appears somewhat clumsy, it is worth noting that by adding only the simple concept of nested quasi-quotes, complex manipulation of the meta-system is possible. Converge’s current approach is not without its limitations. Its chief problem is that it can only relate one source code location to any given VM instruction. There is thus an ‘either / or’ situation in that the user can choose to record either the definition point of the quasi-quoted code, or change it to elsewhere (e.g. to record the splice point). It would be of considerable benefit to the user if it is possible to record all locations which a given VM instruction relates to. Assuming the appropriate changes to the compiler and VM, then the only user-visible change would be that src info to var would append src file and src offset information within a given ITree, rather than overwriting the information it already possessed. 3.14 Related work Perhaps surprisingly, the template system in C++ has been found to be a fairly effective, if crude, mechanism for performing compile-time meta-programming [Vel95, CJOT04]. Essentially the template system can be seen as an ad-hoc functional language which is interpreted at compile-time. However this approach is inherently limited compared to the other approaches described in this section. The dynamic OO language Dylan – perhaps one of the closest languages in spirit to Converge – has a similar macro system [BP99] to Scheme. In both languages there is a dichotomy between macro code and normal code; this is particularly pronounced in Dylan, where the macro language is quite different from the main Dylan language. As explained in the introduction, languages such as Scheme need to be able to identify macros as distinct from normal functions (although Bawden has suggested a way 6 In the current implementation, the src info to var actually mutates ITrees, but for reasons explained in section 4.3.1 this will not be possible in the future. 32 to make macros first-class citizens [Baw00]). The advantage of explicitly identifying macros is that there is no added syntax for calling a macro: macro calls look like normal function calls. Of course, this could just as easily be considered a disadvantage: a macro call is in many senses rather different than a function call. In both schemes, macros are evaluated by a macro expander based on patterns – neither executes arbitrary code during macro expansion. This means that their facilities are limited in some respects – furthermore, overuse of Scheme’s macros can lead to complex and confusing ‘language towers’ [Que96]. Since it can execute arbitrary code at compiletime Converge does not suffer from the same macro expansion limitations, but whether moving the syntax burden from the point of macro definition to call site will prevent the comprehension problems associated with Scheme is an open question. Whilst there are several proposals to add macros of one sort or another to existing languages (e.g. Bachrach and Playford’s Java macro system [BP01]), the lack of integration with their target language thwarts practical take-up. Nemerle [SMO04] is a statically typed OO language, in the Java / C# vein, which includes a macro system mixing elements of Scheme and TH’s systems. Macros are not first-class citizens, but AST’s are built in a manner reminiscent of TH. The disadvantage of this approach is that calculations often need to be arbitrarily pushed into normal functions if they need to be performed at compile-time. Comparisons between Converge and TH have been made throughout this section – I do not repeat them here. MetaML is TH’s most obvious forebear and much of the terminology in Converge has come from MetaML via TH. MetaML differs from TH and Converge by being a multi-stage language. Using its ‘run’ operator, code can be constructed and run (via an interpreter) at run-time, whilst still benefiting from MetaML’s type guarantees that all generated programs are type-correct. The downside of MetaML is that new definitions can not be introduced into programs. The MacroML proposal [GST01] aims to provide such a facility but – in order to guarantee type-correctness – forbids inspection of code fragments which limits the features expressivity. Significantly, with the exception of Dylan, I know of no other dynamically typed OO language in the vein of Converge which supports any form of compile-time metaprogramming natively. 4 Implications of Converge’s compile-time meta-programming for other languages and their implementations I believe that Converge shows that compile-time meta-programming facilities can be added in a seamless fashion to a dynamically-typed OO language and that such facilities provide useful functionality not available previously in such languages. In this section I first pinpoint the relatively minimal requirements on language design necessary to allow the safe and practical integration of compile-time meta-programming facilities. Since the implementation of such a facility is quite different from a normal language compiler, I then outline the makeup of the Converge compiler to demonstrate 33 how an implementation of such features may look in practice. Finally I discuss the requirements on the interface between user code and the languages compiler. 4.1 Language design implications Although Converge’s compile-time meta-programming facilities have benefited slightly from being incorporated in the early stages of the language design, there is surprisingly little coupling between the base language and the compile-time meta-programming constructs. The implications on the design of similar languages can thus be boiled down to the following two main requirements: 1. It must be possible to determine all namespaces statically, and also to resolve variable references between namespaces statically. This requirement is vital for ensuring that scoping rules in the presence of compile-time meta-programming are safe and practical (see section 3.5). Slightly less importantly, this requirement also allows functions called at compile-time to be stored in the same module as splices which call them whilst avoiding the forward reference problem (see section 3.9). 2. Variables within namespaces other than the outermost module namespace must be α-renameable without affecting the programs semantics. This requirement is vital to avoid the problem of variable capture. Note that there is an important, but non-obvious, corollary to the second point: when variables and slot names overlap then α-renaming can not take place. In section 3.5 we saw that, in Converge, top-level module definitions can not be renamed because the variable names are also the slot names of the module object. Since Converge forces all accesses of class fields via the self variable, Converge neatly sidesteps another potential place where this problem may arise. Fortunately, whilst many statically typed languages allow class fields to be treated as normal variables (i.e. making the self. prefix optional) most dynamically typed languages take a similar approach to Converge and should be equally immune to this issue in that context. Only two constructs in Converge are dedicated to compile-time meta-programming. Practically speaking both constructs would need to be added to other languages: 1. A splicing mechanism. This is vital since it is the sole user mechanism for evaluating expressions at compile-time. 2. A quasi-quoting mechanism to build up AST’s. Although such a facility is not strictly necessary, experience suggests that systems without such a facility tend towards the unusable [WC93]. 4.2 Compiler structure Typical language compilers follow a predictable structure: a parser creates a parse tree; the parse tree may be converted into an AST; the parse tree or AST is used to 34 $ Bytecode Generation ITree Generation Parsing [| |] [| |] [| |] Quasiquotes Mode $ Splice Mode Figure 2: Converge compiler states. generate target code (be that VM bytecode, machine code or an intermediate language). Ignoring optional components such as optimizers, one can see that normal compilers need only two or three major components (depending on the inclusion or omission of an explicit AST generator). Importantly the process of compilation involves an entirely linear data flow from one component to the next. Compile-time meta-programming however necessitates a different compiler structure, with five major components and a non-linear data flow between its components. In this section I detail the structure of the Converge compiler, which hopefully serves as a practical example for compilers for other languages. Whether existing language compilers can be retro-fitted to conform to such a structure, or whether a new compiler would need to be written can only be determined on a case-by-case basis; however in either case this general structure serves as an example. Figure 2 shows a (slightly non-standard) state-machine representing the most important states of the Converge compiler. Large arrows indicate a transition between compiler states; small arrows indicate a corresponding return transition from one state to another (in such cases, the compiler transitions to a state to perform a particular action and, when complete, returns to its previous state to carry on as before). Each of these states also corresponds to a distinct component within the compiler. The stages of the Converge compiler can be described thus: 1. Parsing. The compiler parses an input file into a parse tree. Once complete, the compiler transitions to the next state. 2. ITree Generation. The compiler converts the parse tree into an ITree; this stage continues until the complete parse tree has been converted into an ITree. Since ITree’s are exposed directly to the user, it is vital that the parse tree is converted into a format that the user can manipulate in a practical manner 7. (a) Splice mode / bytecode generation. When it encounters a splice annotation in the parse tree, the compiler creates a temporary ITree representing a module. It then transitions temporarily to the bytecode generation state to compile. The compiled temporary module is injected into the running 7 An early, and naı̈ve, prototype of the Converge compiler exposed parse trees directly to the user. This quickly lead to spaghetti code. 35 VM and executed; the result of the splice is used in place of the annotation itself when creating the ITree. (b) Quasi-quotes mode / splice mode. As the ITree generator encounters quasi-quotes in the parse tree, it transitions to the quasi-quote mode. Quasiquote mode creates an ITree respecting the scoping rules and other features of section 3.5. If, whilst processing a quasi-quoted expression, a splice annotation is encountered, the compiler enters the splice mode state. In this state, the parse tree is converted to an ITree in a manner mostly similar to the normal ITree Generation state. If, whilst processing a splice annotation, a quasi-quoted expression is encountered, the compiler enters the quasi-quotes mode state again. If, whilst processing a quasi-quoted expression, a nested quasiquoted expression is encountered the compiler enters a new quasi-quotes mode. 3. Bytecode generation. The complete ITree is converted into bytecode and written to disk. 4.3 Compiler interface Converge provides the CEI module which user code can use to interact with the language compiler. Similar implementations will require a similar interface to allow two important activities: 1. The creation of fresh variable names (see section 3.6.2). This is vital to provide a mechanism for the user to generate unique names which will not clash with other names, and thus will prevent unintended variable capture. To ensure that all fresh names are unique, most practical implementations will probably choose to inspect and restrict the variable names that a user can use within ITree’s via an analogue to Converge’s name interface; this is purely to prevent the user inadvertently using a name which the compiler has guaranteed (or might in the future guarantee) to be unique. 2. The creation of arbitrary AST’s. Since it is extremely difficult to make a quasiquote mechanisms completely general without making it prohibitively complex to use, there are likely to be valid AST’s which are not completely expressible via the quasi-quotes mechanism. Therefore the user will require a mechanism to allow them to create arbitrary AST fragments via a more-or-less traditional meta-programming interface [WC93]. 4.3.1 Abstract syntax trees One aspect of Converge’s design that has proved to be more important than expected, is the issue of AST design. In typical languages, the particular AST used by the compiler 36 is never exposed in any way to the user. Even in Converge, for many users the particulars of the ITree’s they generate via the quasi-quotes mechanism are largely irrelevant. However those users who find themselves needing to generate arbitrary ITree’s via the CEI interface, and especially those (admittedly few) who perform computations based on ITree’s, find themselves disproportionately affected by decisions surrounding the ITree’s representation. At the highest level, there are two main choices surrounding AST’s. Firstly should it be represented as an homogeneous, or heterogeneous tree? Secondly should the AST be mutable or immutable? The first question is relatively easy to answer: experience suggests that homogeneous trees are not a practical representation of a rich AST. Whilst parse trees are naturally homogeneous, the conversion to an AST leads to a more structured and detailed tree that is naturally heterogeneous. Let us then consider the issue of AST mutability. Initially Converge supported mutable AST’s; whilst this feature has proved useful from time to time, it has also proved somewhat more dangerous than expected. This is because one often naturally creates references to a given AST fragment from more than one node. Changing a node which is referenced by more than one other node can then result in unexpected changes, which all too frequently manifest themselves in hard to debug ways. Since it is not possible to check for this problem in the general case, the user is ultimately responsible for ensuring it does not occur; in practise this has proved to be unrealistic, and gradually all ITree-mutating code has been banished from Converge code. Future versions of Converge will force ITree’s to be immutable, and I would recommend other languages consider this point carefully. 5 Syntax extension for DSL’s Converge has a simple but powerful facility allowing users to embed arbitrary sequences of tokens within Converge source files. At compile-time these tokens are passed to a designated user function, which is expected to return an AST. This allows the user to extend the languages syntax in an arbitrary fashion, meaning that DSLs can be embedded within normal Converge code. Although this feature is somewhat less developed than the other aspects of Converge, it is useful even its current state. Essentially a DSL fragment is an indented block containing an arbitrary sequence of tokens. The DSL block is introduced by a variant on the splice syntax $< expr > where expr should evaluate to a function (the DSL implementation function). The DSL function will be called at compile-time with a list of tokens, and is expected to return an AST which will replace the DSL block in the same way as a normal splice. Compile-time meta-programming is thus the mechanism which facilitates embedding DSLs. An example DSL fragment is as follows. Colloquially this block is referred to as ‘a TM.model class’: import TM.TM $<TM.model_class>: 37 abstract class ML1_Element { name : String; inv nonempty_name: name != null and name.len() > 0 } Note that the DSL fragment is written in an entirely different syntax than Converge itself. Currently DSL blocks are automatically tokenized by the Converge compiler using its default tokenization rules — this is not a fundamental requirement of the technique, but a peculiarity of the current implementation. More sophisticated implementations might choose to defer tokenization to the DSL implementation function. However using the Converge tokenizer has the advantage that normal Converge code can be embedded inside the DSL itself assuming an appropriate link from the DSL’s grammar to the Converge grammar. 5.1 DSL implementation functions DSL implementation functions follow a largely similar sequence of steps in order to translate the input tokens into an ITree: 1. Alter the input tokens as necessary. Since DSL’s often use keywords that are not part of the main Converge grammar, such alterations mostly take the form of replacing ID tokens with specific keyword tokens. 2. Parse the input tokens according to the DSL’s grammar. 3. Traverse the parse tree, translating it into an ITree. Section 6 explores these steps in greater detail via a concrete example. 5.2 Related work Real-world implementations of a similar concept are surprisingly rare. The Camlp4 pre-processor [dR03] allows the normal OCaml grammar to be arbitrarily extended, and is an example of a heterogeneous syntax extension system in that the system doing the extension is distinct from the system being extended. The MetaBorg system [BV04] is a heterogeneous system that can be applied to any language; more sophisticated than the Camlp4 pre-processor, from an external point of view it more closely resembles Converge’s functionality, although the implementations and underlying philosophies are still very different. I am currently aware of only two homogeneous syntax extension systems apart from Converge. Nemerle [SMO04] allows limited syntax extension via its macro scheme. The commercial XMF tool [CESW04] presents only a small core grammar, with many normal language concepts being grammar extensions on top of the core grammar. Grammar extensions are compiled down into XMF’s AST. XMF is thus much closer in spirit to Converge, although the example grammar extensions available suggest that XMF’s compile-time facilities may be less powerful than Converge’s, 38 type Classifier name : String parents *{ordered} PrimitiveDataType Class name : String Attribute is_primary : bool name : String attrs *{ordered} Figure 3: ‘Simple UML’ model. seemingly being based on a simplified version of TH’s features. If true, this may limit the complexity of the grammar extensions. 6 Modelling language DSL This section presents an example of a Converge DSL for expressing typed modelling languages; modelling languages can be instantiated create models. In its current simplistic form, the modelling DSL operates with a fixed number of meta-levels in that it defines modelling languages that can create models, but those models are terminal instances (in ObjVLisp’s terminology) — that is, they can not be used to create new objects. This section serves as an example of Converge’s syntax extension system, and fleshes out the method of section 5.1. 6.1 Example of use The typed modelling language DSL is housed within the package TM; the DSL implementation function model class is contained within the TM module within the package. The following fragment uses the DSL to express a model of a simplified UML-esque modelling language as shown in figure 3: import TM.TM $<TM.model_class>: abstract class Classifier { name : String; } class PrimitiveDataType extends Classifier { } class Class extends Classifier { parents : Seq(Class); attrs : Seq(Attribute); inv unique_names: attrs->forAll(a1 a2 | a1 != a2 implies a1.name != a2.name) } 39 class Attribute { name : String; type : Classifier; is_primary : bool; } Note that although this particular examples shows a model of a modelling language, the DSL is capable of expressing any type of model. The TM.model class DSL implementation function translates each class in the model into a function in Converge which creates model objects. As a useful convenience, each constructor function takes arguments which correspond to the order attributes are specified in the model class. If a model class has parents, their attributes come first, and so on recursively. Model objects can have their slots accessed by name. Note that since the modelling language is typed, setting attributes either via the constructor function or through assigning to a slot forces the value to be of the correct type. Types can be of any model class (the DSL allows forward references), int, String, bool (where true and false are represented by 1 and 0 respectively), or sequences or sets of the preceding types. Note that sequences and sets can be nested arbitrarily. Model classes can contain invariants which are written in OCL; invariants are checked after an object has been initialized with values, and on every subsequent slot update. Currently only a subset of OCL 1.x is implemented, but the subset covers several different areas of OCL; implementing full OCL 1.x would be a relatively simple extension. Assuming the above is held in a file Simple UML.cv, one can then use the use the Simple UML modelling language to create models. The following example creates model classes Dog and Person, with Dog having an attribute owner of type Person: person := Simple_UML.Class("Person") dog := Simple_UML.Class("Dog") dog.attrs.append(Simple_UML.Attribute("owner", person, 0)) One can arbitrarily manipulate models in a natural fashion: dog.name := "Doggy" Attempting to update a model in a way that would conflict with its type information results in an exception being raised. For example, attempting to assign an integer to the Dog model class’ name raises the following exception: Traceback (most recent call last): File "Ex1.cv", line 42, column 4, in main File "TM/TM.cv", line 162, column 5, in set_slot Exception: Instance of ’Class’ expected object of type ’String’ for slot ’name’. In similar fashion, if one violates the unique names constraint by adding two attributes called owner to the Dog model class, the following exception is raised: Traceback (most recent call last): File "Ex1.cv", line 45, column 17, in main File "TM/TM.cv", line 327, column 31, in append File "TM/TM.cv", line 407, column 3, in _class_class_check_invs Exception: Invariant ’unique_names’ violated. 40 As can be seen, the result of using the TM.model class DSL is a natural embedding of an arbitrary modelling language within Converge. Furthermore by recording, and enforcing, type information using the modelling language DSL provides guarantees about models that would not have been the case if they had been implemented as normal Converge class’s. The typed modelling language DSL neatly sidesteps the data representation problems found in typical GPL’s. In the following sections I outline how this DSL is implemented. 6.2 Data model The TM package provides its own ObjVLisp style data model which is similar to, but distinct from, the Converge data model of section 2.4. The TM package needs to provide a new data model since the default Converge data model is inherently untyped; whilst figure 1 showed the core data model with types, such type information is purely for the benefit of the reader. In contrast, the TM data model is inherently typed, and the type information is used to enforce the correctness of models. The only exception to this is that functions are currently untyped; it would be relatively simple to extend the implementation to record and enforce functions’ type information. Figure 4 shows the TM data model. As in Converge, a bootstrapping phase is needed to set up the meta-circular data model. MObject and MClass are so named to avoid clashing with the builtin classes Object and Class. Similarly, method and attribute names which might conflict, or be confused with, those found in normal Converge classes are named differently. For example init becomes initialize, to str becomes to string and instance of becomes of. For brevity, and for easy interaction with external code, the TM package does not directly replicate all builtin Converge types such as strings; builtin Converge types are treated internally as instances of MObject. Once cosmetic differences between the two are ignored, some important differences in the TM and Converge data models become apparent. Most importantly the TM data model has the standard statically typed OO languages notions of separate methods and attributes. TM mclasses are also different in that they can be abstract (i.e. can not be directly instantiated) and have at most one super class. MObject classes possess a mod id slot which is a unique identifier, and which is typed as String to allow flexibility over the format of identifiers. The mod id slot plays an important rôle in change propagating transformations. As all this might suggest, the TM data model is intended to closely match the data model found within statically typed OO languages such as Java than the. Since methods and attributes are housed separately within classes, model instances require only a slot per attribute; invoking a method on an object searches the objects of class (and its superclasses) for an appropriate method. This is achieved by making use of the Converge MOP (see section 2.7). Although the actual implementation is relatively complex, a simplified version demonstrates the salient points. All model objects are instances of the Converge class Raw Object which is initialized with a blank slot per attribute 41 MObject mod_id : String initialize(*MObject) : void to_string() : String of super_class 0, 1 MClass attrs : Dict{String : MObject} invariants : Seq{MObject} is_abstract : bool methods : Dict{String : MObject} name : String check_invs() : void initialize(*MObject) : void new_(*MObject) : MObject Figure 4: TM data model. of a model class. The Converge MOP is overridden via a custom get slot function in the Raw Object class. If a slot name matches an attribute slot, that value is returned. Otherwise the model objects of class and, if necessary its superclasses, are searched for a method of the appropriate name. Finally, if a method is not found then if the slot name matches that of a normal Converge slot in the Raw Object instance, the value is returned; otherwise an exception is raised. The following, much simplified, version of the code shows the skeleton of the Raw Object class and part of its MOP: 1 2 3 4 5 class _Raw_Object: func init(attr_names): self._attr_slots := Dict{} for attr_name := attr_names.iterate(): self._attr_slots[attr_name] := null 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 func get_slot(name): if self._attr_slots.contains(name): return self._attr_slots[name] else: class_ := self._attr_slots["of"] while 1: if class_.methods.contains(name): return Func_Binding(self, class_.methods[name]) if (class_ = class_.super_class) == null: break if exbi Object.has_slot(name): return exbi Object.get_slot(name) else: raise Exceptions.Slot_Exception(Strings.format( \ "No such model / Converge slot ’%s’", name)) A few notes are in order. Firstly the class variable on line 11 is so named since class is a reserved keyword in Converge; by convention variable names are suffixed by ‘ ’ if they would otherwise clash with a reserved word. Note that definitions prefixed by ‘ ’ are conventionally considered to be private to the module or class they are contained within. On line 14, the Func Binding class creates a binding which, when invoked, will call the Converge function class .methods[name] with its self variable bound to self (i.e. the Raw Object instance; see section 2.2 for more details about functions and function bindings). The ability to create function 42 bindings in this fashion is an important feature of Converge, allowing a large deal of control over the behaviour of objects. The TM packages data model can be considered to be a suitable template for suggesting how more powerful typed modelling languages – perhaps including packages and package inheritance [ACE+ 02], or allowing classes to inherit from more than one superclass) – might be naturally represented in a Converge DSL. 6.3 Pre-parsing and grammar Before the TM.model class DSL can parse its input, it first iterates through the input tokens searching for tokens which have type ID and value any of abstract, and, at, collect, extends, forAll, implies, inv, Seq, Set. Such tokens are replaced by a keyword token, whose type is the ID’s value. Furthermore since the TM.model class DSL is intended to emulate typed languages such as C and Java, it implements a white space insensitive grammar; thus all INDENT, DEDENT, and NEWLINE tokens are removed from the input. The modified token list is then parsed according to the following grammar: top_level ::= { class }* class ::= class_abstract "CLASS" "ID" class_super "{" { class_field }* "}" class_abstract ::= "ABSTRACT" ::= class_super ::= "EXTENDS" "ID" ::= class_field ::= field_type ::= invariant field_type ::= "ID" ":" type ";" type ::= "ID" ::= "SEQ" "(" type ")" ::= "SET" "(" type ")" invariant ::= "INV" "ID" ":" expr expr ::= ::= ::= ::= ::= ::= ::= ::= int string slot_lookup application binary seq set "ID" int ::= "INT" string ::= "STRING" slot_lookup ::= ::= ::= ::= ::= ::= ::= ::= forall at collect application %precedence 20 %precedence 15 %precedence 10 expr "." "ID" expr "-" ">" forall expr "-" ">" at expr "-" ">" collect "FORALL" "(" "ID" "|" expr ")" "FORALL" "(" "ID" "ID" "|" expr ")" "AT" "(" expr ")" "COLLECT" "(" "ID" "ID" "=" expr "|" expr ")" ::= expr "(" expr { "," expr }* ")" ::= expr "(" ")" 43 binary ::= ::= ::= ::= ::= ::= ::= ::= expr expr expr expr expr expr expr expr "+" expr "-" expr ">" expr "<" expr "==" expr "!=" expr "IMPLIES" expr "AND" expr %precedence %precedence %precedence %precedence %precedence %precedence %precedence %precedence seq ::= "SEQ" "{" expr ".." expr "}" ::= "SEQ" "{" expr { "," expr }* "}" ::= "SEQ" "{" "}" set ::= "SET{" expr { "," expr }* "}" ::= "SET{" "}" 30 30 20 20 20 20 10 10 Most of this grammar is straightforward, although it is worth noting a few peculiarities that result from the fact that tokenization is performed by the Converge tokenizer. For example, ‘Set{’ is a single token (since Set{...} builds up a set in normal Converge). The equivalent notation for sets is represented by two tokens: ‘Seq’ (a new keyword introduced by the DSL) followed by ‘{’. Fortunately in practise, such idiosyncrasies are largely hidden from, and irrelevant to, the DSL’s users. 6.4 Traversing the parse tree The main part of the DSL implementation function is concerned with traversing the parse tree, and translating it into an appropriate ITree. At a high-level, the translation is fairly simple: each model class is converted into an object capable of creating model instances. The CPK provides a simple traversal class (essentially a Converge equivalent of that found in the SPARK parser [AH02]) which provides the basis for most such translations. Users need only subclass the Traverser class and create a function prefixed by t name for each rule in the grammar. The Traverser class provides a preorder function will traverse an input parse tree in preorder fashion, calling the appropriate t name function for each node encountered in the tree. Note that each t name function can choose whether to invoke the preorder rule on sub-nodes, or whether it is capable of processing the sub-nodes itself. For example, the TM.model class function defines a traversal class Model Class Creator which translates the DSL’s parse tree. An idealized version of the beginning of this class looks as follows: import CPK.Traverser class Model_Class_Creator(Traverser.Traverser): func translate(): return self.preorder() func _t_top_level(node): // top_level ::= { class }* classes := [] for class_node := node[1 : ].iterate() classes.extend(self.preorder(class_node)) return classes func _t_class(node): // class ::= class_abstract "CLASS" "ID" class_super "{" // class_field }* "}" ... return [| 44 { class $<<CEI.name(node[3].value)>>: ... |] As this example shows, by automatically creating a parse tree and presenting a simple but powerful mechanism for traversing that parse tree, the CPK imposes a low burden on its users. 6.5 Translating The actual translation of the parse tree to a ITree involves much repetition, and contains implementation details which are irrelevant to this paper. The first point to note about the translation is that the resulting ITree largely follows the structure of the parse tree. Having the translation follow the structure of the parse tree is desirable because it significantly lowers the conceptual burden involved in creating and comprehending the translation. In this subsection I highlight some interesting aspects of the translation; interested readers can use this as a step to exploring the full translation in the TM package. 6.5.1 OCL expressions Translating the OCL subset into Converge is a simple place to start in the translation because it is mostly simple and repetitive. For example, converting binary expressions from OCL into Converge is mostly a direct translation as the elided t binary traversal function shows: func _t_binary(node): // binary ::= expr "+" expr // ::= expr "<" expr // ::= expr "==" expr lhs := self.preorder(node[1]) rhs := self.preorder(node[3]) if node[2].type == "+": return [| $<<lhs>> + $<<rhs>> |] elif node[2].type == ">": return [| $<<lhs>> > $<<rhs>> |] elif node[2].type == "==": return [| func ocl_equals() { lhs := $<<lhs>> if lhs.conforms_to(Int) | lhs.conforms_to(String): return lhs == $<<rhs>> else: return lhs is $<<rhs>> }() |] Note that there is a slight complexity in translating the == operator, since OCL defines equality between objects to be based on their value if they are a primitive type, and on their identity if they are a model element. Whilst this is simple to encode as a sequence of expressions, it slightly complicates the t class traversal function, which is expected to a return only a single quasi-quoted expression. In order to work around this limitation we combine the necessary sequence of instructions into a function; the quasi-quotes returns the invocation of this function which is thus a single expression. This idiom occurs frequently in the translation. 45 6.5.2 Forward references Forward references between model classes might appear to slightly muddy the structure of the translation. Consider the following example: $<TM.model_class>: class Dog { owner : Person; } class Person { name : String; } Assuming a simple translation, the result would be similar to the following converge code: class Dog: attributes := Dict{"owner" : Person} name := "Dog" class Person: attributes := Dict{"name" : String} name := "Person" Such code would compile correctly, but lead to an exception being raised at run-time since the Person class will not have assigned a value to the Person variable when it is accessed in the Dog class. A standard approach to this problem would be to make the attributes field a function; by placing the reference to Person a function, the variable access would be deferred until after the Person variable contained a value. The TM.model class DSL takes an alternative approach which is simplistic and, whilst not generally applicable, effective. Essentially the TM module keeps a record of all model classes encountered. When a new model class is created (i.e. when importing a model containing a TM.model class block), it registers itself with the TM module. Rather than directly referring to model classes, type references are strings of the target model class name – when a model class needs to be retrieved, its name is looked up in the TM registry, and the appropriate object returned. Thus forward references are a non-issue, since references are only resolved when necessary. The reason the TM.model class DSL takes this approach is that all model classes live in the same namespace; when we come to transforming model elements it aids brevity that model classes do not need to be prefixed by a package or module name. 6.5.3 Model class translation The suggestion up to this point has largely been that model classes have been directly translated to normal Converge classes. In fact, model classes are instances of the MClass class. Whilst this would suggest that we can use the metaclass keyword shown in section 2.4, this is not possible since MClass requires more information than a normal Converge class. Normal Converge classes take only three parameters name, supers, fields whereas a model class requires information about whether it is 46 abstract, its invariants and so on. It is thus necessary to create the class manually. Fortunately this is relatively simple largely because the bound func keyword allows bound functions to be expressed outside a class. A much elided, and slightly simplified, version of the t class traversal function is as follows: is abstract := bool class name := String super := String or null attrs := Dict{name : type} invariants := List of tuples [name, function] operations := List of tuples [name, function] init func var := CEI.ivar(CEI.fresh name()) return [d| bound func initialize(*args): super attrs := all attrs($<<super >>, 1) if args.len() > (super attrs.len() + \ $<<CEI.lift(attrs.len())>>): raise Exceptions.Parameters Exception("Too many args") super args pos := Maths.min(super attrs.len(), args.len()) Func Binding(self, $<<super >>.methods["initialize"]). \ apply(args[ : super args pos]) $<<init func body>> $<<CEI.ivar(CEI.name(class name))>> := MClass( \ $<<is abstract>>, $<<CEI.lift(class name)>>, \ $<<super >>, $<<CEI.lift(attrs)>>, \ $<<CEI.idict(operations + [[CEI.lift("initialize"), \ init func var]])>>, $<<CEI.ilist(invariants)>>) |] Essentially the t class traversal function first evaluates and transforms the details of the model class, placing information such as whether the class is abstract into appropriately named variables. Finally it returns quasi-quoted code which contains two things: a function to initialize model class instances, and finally the instantiation of MClass itself. The arguments that MClass itself requires are hopefully obvious due to the names of the variables passed to it in this example. 6.5.4 Summary of translation Whilst this section has tersely presented the translation of a TM.model class block, I hope that it shows enough detail to suggest that the bulk of the translation is simple work, with only one or two areas requiring the use of more esoteric Converge features. Section B shows the pretty printed ITree resulting from the translation of the example in section 6.1. 6.6 Diagrammatic visualization A useful additional feature of the TM package is its ability to visualize modelling languages and model languages as diagrams. Visualization makes use of the fact 47 that the TM data model is fully reflective, making the traversal and querying of objects trivial. The Visualizer module defines several visualization functions, all of which use the GraphViz package [GN00] to create diagrams. For example, the visualize modelling language function takes a list of model classes, and visualizes them as a standard class diagram. Figure 5 shows the automatic visualization of the Simple UML modelling language originally shown in figure 3. Note that the modelling language visualization function explicitly shows that all model classes are subclasses of MObject. MObject mod_id : String of to_string() initialize() Classifier name : String initialize() PrimitiveDataType Class parents * ordered initialize() type initialize() attrs * ordered Attribute is_primary : bool name : String initialize() Figure 5: ‘Simple UML’ modelling language visualized. The Visualizer is also able to visualize models as UML object diagrams. Figure 6 shows the visualization of the following module: person := Simple_UML.Class("Person") dog := Simple_UML.Class("Dog") dog.attrs.append(Simple_UML.Attribute("owner", person, 0)) 7 Future work Because the core of Converge is a mix of established languages, the core language is largely stable. The implementation of the compile-time meta-programming facilities is currently less than satisfactory in one or two areas, but is eminently usable. One feature in particular that appears to be confuse new users to the language relates to the very different effects of the splice annotation. The syntax, inherited from Template 48 :Class mod_id = "9" name = "Dog" attrs :Attribute mod_id = "10" is_primary = 0 name = "owner" type :Class mod_id = "8" name = "Person" Figure 6: A ‘Simple UML’ model language visualized. Haskell, means that $ behaves very differently when inside (a simple replacement of the splice annotation) and outside (cause compile-time evaluation) quasi-quotes. A simple change of syntax may suffice to solve this problem, or it may be considered to be an inevitable part of the learning curve that compile-time meta-programming presents. Although Converge’s error reporting facilities are at least as good as any comparable language, there is still room for considerable improvement from the users point of view. Although section 3.13 used nested quasi-quotes to customise error reports, it may be necessary to find a lighter weight technique if DSL authors are to be encouraged to provide high quality error reporting. The syntax extension feature presents the greatest opportunity for future work. The most obvious improvement would be to allow the user to provide their own tokenization facility. This may result in simply passing a single string to the DSL implementation function, or it may involve a more sophisticated interaction between the Converge tokenizer and parser and the DSL tokenizer and parser. For example parsing algorithms such as Pack Rat parsing [For02] allow the conflation of tokenizing and parsing in a way that might lend themselves to syntax extension. 8 Acknowledgments This work has been funded by a grant from Tata Consultancy Services. My thanks to Kelly Androutsopoulos for insightful comments on an early draft of part of this paper. 49 A Converge grammar This section lists the CPK grammar for Converge. This is extracted directly from the Converge compiler file Compiler/CV Parser.cv: top_level ::= definition { "NEWLINE" definition }* ::= definition ::= ::= ::= ::= ::= class_def func_def import var { "," var }* ":=" expr splice import ::= "IMPORT" dotted_name import_as { "," dotted_name import_as }* dotted_name ::= "ID" { "." "ID" }* import_as ::= "AS" "ID" ::= class_def ::= "CLASS" class_name class_supers class_metaclass ":" "INDENT" class_fields "DEDENT" class_name ::= "ID" ::= splice class_supers ::= "(" expr { "," expr }* ")" ::= class_metaclass ::= "METACLASS" expr ::= class_fields ::= class_field { "NEWLINE" class_field }* class_field ::= class_def ::= func_def ::= var ":=" expr ::= splice ::= "PASS" func_def ::= func_type func_name "(" func_params ")" ":" "INDENT" func_nonlocals expr_body "DEDENT" ::= func_type func_name "(" func_params ")" "{" "INDENT" func_nonlocals expr_body "DEDENT" "NEWLINE" "}" func_type ::= "FUNC" ::= "BOUND_FUNC" ::= "UNBOUND_FUNC" func_name ::= "ID" ::= "+" ::= "-" ::= "/" ::= "*" ::= "<" ::= ">" ::= "==" ::= "!=" ::= ">=" ::= "<=" ::= splice ::= func_params ::= func_params_elems "," func_varargs ::= func_params_elems ::= func_varargs ::= func_params_elems ::= var func_param_default { "," var func_param_default }* ::= splice func_param_default ::= ":=" expr ::= func_varargs ::= "*" var ::= splice func_nonlocals ::= "NONLOCAL" "ID" { "," "ID" }* "NEWLINE" ::= expr_body ::= expr { "NEWLINE" expr }* 50 expr ::= ::= ::= ::= ::= ::= ::= ::= ::= ::= ::= ::= ::= ::= ::= ::= ::= ::= ::= ::= ::= ::= ::= ::= ::= ::= ::= ::= ::= ::= ::= ::= ::= ::= ::= ::= ::= class_def func_def while if for try number var dict set list dict string slot_lookup list application lookup slice exbi return yield raise assert break continue conjunction alternation assignment not neg binary comparison pass import splice quasi_quotes brackets if ::= "IF" expr if_else ::= "IF" expr { if_elif if_elif ::= "NEWLINE" ::= "NEWLINE" "NEWLINE" if_else ::= "NEWLINE" ::= "NEWLINE" "}" ::= %precedence 50 %precedence 40 %precedence 40 %precedence 40 %precedence %precedence %precedence %precedence %precedence %precedence %precedence 10 10 15 17 35 30 20 %precedence 100 ":" "INDENT" expr_body "DEDENT" { if_elif }* "{" "INDENT" expr_body "DEDENT" "NEWLINE" "}" }* if_else "ELIF" expr ":" "INDENT" expr_body "DEDENT" "ELIF" expr "{" "INDENT" expr_body "DEDENT" "}" "ELSE" ":" "INDENT" expr_body "DEDENT" "ELSE" "{" "INDENT" expr_body "DEDENT" "NEWLINE" while ::= "WHILE" expr ":" "INDENT" expr_body "DEDENT" exhausted broken ::= "WHILE" expr for ::= "FOR" expr ":" "INDENT" expr_body "DEDENT" exhausted broken ::= "FOR" expr try ::= "TRY" ":" "INDENT" expr_body "DEDENT" { try_catch }* try_else try_catch ::= "NEWLINE" "CATCH" expr try_catch_var ":" "INDENT" expr_body "DEDENT" try_catch_var ::= "INTO" var ::= try_else ::= "NEWLINE" "ELSE" ":" "INDENT" expr_body "DEDENT" ::= exhausted ::= "NEWLINE" "EXHAUSTED" ":" "INDENT" expr_body "DEDENT" ::= broken ::= "NEWLINE" "BROKEN" ":" "INDENT" expr_body "DEDENT" ::= number ::= "INT" var ::= "ID" 51 ::= "&" "ID" ::= splice string ::= "STRING" slot_lookup ::= expr "." "ID" ::= expr "." splice list ::= "[" expr { "," expr }* "]" ::= "[" "]" dict ::= "DICT{" expr ":" expr { "," expr ":" expr }* "}" ::= "DICT{" "}" set ::= "SET{" expr { "," expr }* "}" ::= "SET{" "}" application ::= expr "(" expr { "," expr }* ")" ::= expr "(" ")" lookup ::= expr "[" expr "]" slice ::= ::= ::= ::= expr expr expr expr "[" "[" "[" "[" expr ":" expr "]" ":" expr "]" expr ":" "]" ":" "]" exbi ::= "EXBI" expr "." "ID" return ::= "RETURN" expr ::= "RETURN" yield ::= "YIELD" expr raise ::= "RAISE" expr assert ::= "ASSERT" expr break ::= "BREAK" continue ::= "CONTINUE" conjunction ::= expr "&" expr { "&" expr }* alternation ::= expr "|" expr { "|" expr }* assignment ::= assignment_target { "," assignment_target }* assignment_type expr assignment_target ::= var ::= slot_lookup ::= lookup ::= slice assignment_type ::= ":=" ::= "*=" ::= "/=" ::= "+=" ::= "-=" not ::= "NOT" expr neg ::= "-" expr binary ::= expr binary_op expr binary_op ::= "*" %precedence ::= "/" %precedence ::= "%" %precedence ::= "+" %precedence ::= "-" %precedence 40 30 30 20 20 comparison ::= expr comparison_op expr comparison_op ::= "IS" ::= "==" ::= "!=" ::= "<=" 52 ::= ">=" ::= "<" ::= ">" pass ::= "PASS" splice ::= ::= expr_splice ::= block_splice ::= expr_splice block_splice "$" "<" "<" expr ">" ">" "$" "<" expr ">" ":" "INDENT" "JUMBO" "DEDENT" quasi_quotes ::= expr_quasi_quotes ::= defn_quasi_quotes expr_quasi_quotes ::= "[|" "INDENT" expr { "NEWLINE" expr }* "DEDENT" "NEWLINE" "|]" ::= "[|" expr { "NEWLINE" expr }* "|]" defn_quasi_quotes ::= "[D|" definition { "NEWLINE" definition }* "|]" ::= "[D|" "INDENT" definition { "NEWLINE" definition }* "DEDENT" "NEWLINE" "|]" brackets ::= "(" expr ")" B The ‘Simple UML’ modelling language translation The following is the pretty printed ITree that results from translating the Simple UML modelling language shown in section 6.1: Simple_UML.cvb Simple_UML.cv $$1$$ := bound_func initialize_Classifier(*args){ super_attrs := TM._all_attrs(TM.MObject, 1) if args.len() > super_attrs.len() + 1: raise TM.Exceptions.Parameters_Exception("Too many args") super_args_pos := TM.Maths.min(super_attrs.len(), args.len()) TM.Func_Binding(self, TM.MObject.methods["initialize"]).apply(args[0 : \ super_args_pos]) if 0 < args.len() - super_args_pos: self.name := args[super_args_pos + 0] } Classifier := TM.MClass(1, "Classifier", TM.MObject, Dict{"name" : [3]}, \ ["name"], Dict{"initialize" : $$1$$}, []) $$2$$ := bound_func initialize_PrimitiveDataType(*args){ super_attrs := TM._all_attrs(TM._CLASSES_REPOSITORY["Classifier"], 1) if args.len() > super_attrs.len() + 0: raise TM.Exceptions.Parameters_Exception("Too many args") super_args_pos := TM.Maths.min(super_attrs.len(), args.len()) TM.Func_Binding(self, TM._CLASSES_REPOSITORY["Classifier"]. \ methods["initialize"]).apply(args[0 : super_args_pos]) } PrimitiveDataType := TM.MClass(0, "PrimitiveDataType", \ TM._CLASSES_REPOSITORY["Classifier"], Dict{}, [], Dict{"initialize" : $$2$$}, []) $$3$$ := bound_func initialize_Class(*args){ super_attrs := TM._all_attrs(TM._CLASSES_REPOSITORY["Classifier"], 1) if args.len() > super_attrs.len() + 2: raise TM.Exceptions.Parameters_Exception("Too many args") super_args_pos := TM.Maths.min(super_attrs.len(), args.len()) TM.Func_Binding(self, TM._CLASSES_REPOSITORY["Classifier"]. \ methods["initialize"]).apply(args[0 : super_args_pos]) if 0 < args.len() - super_args_pos: self.parents := args[super_args_pos + 0] if 0 >= args.len() - super_args_pos: self.parents := TM.TM_List(self) if 1 < args.len() - super_args_pos: self.attrs := args[super_args_pos + 1] if 1 >= args.len() - super_args_pos: self.attrs := TM.TM_List(self) } 53 Class := TM.MClass(0, "Class", TM._CLASSES_REPOSITORY["Classifier"], \ Dict{"parents" : [0, "Class"], "attrs" : [0, "Attribute"]}, ["parents", \ "attrs"], Dict{"initialize" : $$3$$}, [["unique_names", \ unbound_func unique_names(self){ return unbound_func ocl_for(){ expr := self.attrs for a1 := expr.iterate(): for a2 := expr.iterate(): if not unbound_func ocl_implies(){ if not unbound_func ocl_not_equals(){ lhs := a1 if lhs.conforms_to(TM.Int) | lhs.conforms_to(TM.String): return lhs != a2 else: return not lhs is a2 }(): return 1 if unbound_func ocl_not_equals(){ lhs := a1.name if lhs.conforms_to(TM.Int) | lhs.conforms_to(TM.String): return lhs != a2.name else: return not lhs is a2.name }(): return 1 return TM.fail }(): return TM.fail return 1 }() }]]) $$4$$ := bound_func initialize_Attribute(*args){ super_attrs := TM._all_attrs(TM.MObject, 1) if args.len() > super_attrs.len() + 3: raise TM.Exceptions.Parameters_Exception("Too many args") super_args_pos := TM.Maths.min(super_attrs.len(), args.len()) TM.Func_Binding(self, TM.MObject.methods["initialize"]).apply(args[0 : \ super_args_pos]) if 0 < args.len() - super_args_pos: self.name := args[super_args_pos + 0] if 1 < args.len() - super_args_pos: self.type := args[super_args_pos + 1] if 2 < args.len() - super_args_pos: self.is_primary := args[super_args_pos + 2] } Attribute := TM.MClass(0, "Attribute", TM.MObject, Dict{"name" : [3], \ "type" : "Classifier", "is_primary" : [5]}, ["name", "type", "is_primary"], \ Dict{"initialize" : $$4$$}, []) 54 References [AC96] Martı́n Abadi and Luca Cardelli. A Theory of Objects. Springer, 1996. [ACE+ 02] Biju Appukuttan, Tony Clark, Andy Evans, Stuart Kent, Girish Maskeri, Paul Sammut, Laurence Tratt, and James S. Willans. Unambiguous uml submission to uml 2 infrastructure rfp, September 2002. OMG document ad/2002-06-14. [AH02] John Aycock and R. Nigel Horspool. Practical earley parsing. The Computer Journal, 45(6):620–630, 2002. [Baw99] Alan Bawden. Quasiquotation in lisp. Workshop on Partial Evaluation and Semantics-Based Program Manipulation, January 1999. [Baw00] Alan Bawden. First-class macros have types. In Proc. 27th ACM SIGPLAN-SIGACT symposium on Principles of programming languages, pages 133–141, January 2000. [BC89] Jean-Pierre Briot and Pierre Cointe. Programming with explicit metaclasses in Smalltalk-80. In Proc. OOPSLA ’89, October 1989. [BP99] Jonathan Bachrach and Keith expressions: Lisp power, Playford. dylan style, http://www.ai.mit.edu/people/jrb/Projects/dexprs.pdf D1999. Accessed Sep 22 2004. [BP01] Jonathan Bachrach and Keith Playford. The java syntactic extender (jse). In Proc. OOPSLA, pages 31–42, November 2001. [BS00] Claus Brabrand and Michael Schwartzbach. Growing languages with metamorphic syntax macros. In Workshop on Partial Evaluation and Semantics-Based Program Manipulation, SIGPLAN. ACM, 2000. [BV04] Martin Bravenboer and Eelco Visser. Concrete syntax for objects. Domain-specific language embedding and assimilation without restrictions. In Douglas C. Schmidt, editor, Proc. OOPSLA’04, Vancouver, Canada, October 2004. ACM SIGPLAN. [CESW04] Tony Clark, Andy Evans, Paul Sammut, and James Willans. Applied metamodelling: A foundation for language driven development, September 2004. Available from http://www.xactium.com/ Accessed Sep 22 2004. [CJOT04] Krzysztof Czarnecki, Jörg Striegnitz John O’Donnell, and Walid Taha. DSL implementation in MetaOCaml, Template Haskell, and C++. 3016:50–71, 2004. 55 [CMA93] Luca Cardelli, Florian Matthes, and Martı́n Abadi. Extensible grammars for language specialization. In Proc. Fourth International Workshop on Database Programming Languages - Object Models and Languages, pages 11–31, August 1993. [Coi87] Pierre Cointe. Metaclasses are first class: the ObjVLisp model. In Object Oriented Programming Systems Languages and Applications, pages 156– 162, October 1987. [DGR01] Olivier Danvy, Bernd Grobauer, and Morten Rhiger. A unifying approach to goal-directed evaluation. New Generation Computing, 20(1):53–73, Nov 2001. [DHB92] R. Kent Dybvig, Robert Hieb, and Carl Bruggeman. Syntactic abstraction in scheme. In Lisp and Symbolic Computation, volume 5, pages 295–326, December 1992. [DM95] François-Nicola Demers and Jacques Malenfant. Reflection in logic, functional and object-oriented programming: a short comparative study. In Proc. IJCAI’95 Workshop on Reflection and Metalevel Architectures and Their Applications in AI, pages 29–38, August 1995. [dR03] Daniel de Rauglaudre. Camlp4 - Reference Manual, September 2003. http://caml.inria.fr/camlp4/manual/ Accessed Sep 22 2004. [Ear70] Jay Earley. An efficient context-free parsing algorithm. Communications of the ACM, 13(2), February 1970. [EBN02] Michael D. Ernst, Greg J. Badros, and David Notkin. An empirical analysis of C preprocessor use. IEEE Transactions on Software Engineering, 2002. [For02] Bryan Ford. Packrat parsing: Simple, powerful, lazy, linear time. In International Conference on Functional Programming, pages 36–47, October 2002. [GG93] Ralph E. Griswold and Madge T. Griswold. History of the Icon programming language. j-SIGPLAN, 28(3):53–68, March 1993. [GG96a] Ralph E. Griswold and Madge T. Griswold. The Icon Programming Language. Peer-to-Peer Communications, third edition, 1996. [GG96b] Ralph E. Griswold and Madge T. Griswold. The Implementation of the Icon Programming Language. Peer-to-Peer Communications, third edition, 1996. [GHJV94] Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides. Design Patterns: Elements of Reusable Object-Orientated Software. Addison Wesley, 1994. 56 [GN00] Emden R. Gansner and Stephen C. North. An open graph visualization system and its applications to software engineering. Software – Practice and Experience, 30(11):1203–1233, 2000. [GR89] Adele Goldberg and David Robson. Addison-Wesley, January 1989. [GST01] Steven E. Ganz, Amr Sabry, and Walid Taha. Macros as multi-stage computations: Type-safe, generative, binding macros in macroml. In Proc. International Conference on Functional Programming (ICFP), volume 36 of SIGPLAN. ACM, September 2001. [Gud92] David A. Gudeman. Denotational semantics of a goal-directed language. ACM Transactions on Programming Languages and System, 14(1):107– 125, January 1992. [Jaf03] Aubrey Jaffer. Smalltalk-80: The Language. SCM Scheme Implementation, November 2003. Accessed Sep 16 http://www.swiss.ai.mit.edu/˜jaffer/scm toc 2004. [Jef02] Clinton L. Jeffery. Godiva Language Reference Manual, November 2002. http://www.cs.nmsu.edu/˜jeffery/godiva/godiva.html. [JMPP03] Clinton Jeffery, Robert Parlett. [KCR98] Richard Kelsey, William Clinger, and Jonathan Rees. Revised(5) report on the algorithmic language Scheme. Higher-Order and Symbolic Computation, 11(1):7–105, 1998. Shamim Mohamed, Programming with Ray Pereda, and Unicon, April 2003. http://unicon.sourceforge.net/book/ub.pdf. [KdRB91] Gregor Kiczales, Jim des Rivieres, and Daniel G. Bobrow. The Art of the Metaobject Protocol. MIT Press, 1991. [KFFD86] Eugene Kohlbecker, Daniel P. Friedman, Matthias Felleisen, and Bruce Duba. Hygienic macro expansion. In Symposium on Lisp and Functional Programming, pages 151–161. ACM, 1986. [Pro97] Todd A. Proebsting. Simple translation of goal-directed evaluation. In SIGPLAN Conference on Programming Language Design and Implementation, pages 1–6, 1997. [Que96] Christian Queinnec. Macroexpansion reflective tower. In Proc. Reflection’96, pages 93–104, April 1996. [Sch] Friedrich Wilhelm Schröer. The ACCENT Grammar Language. Accessed Jan 25 http://accent.compilertools.net/language.html 2005. 57 [SCK03] Sean Seefried, Manuel M. T. Chakravarty, and Gabriele Keller. Optimising embedded DSLs using Template Haskell. In Draft Proc. Implementation of Functional Languages, 2003. [SeABP99] Tim Sheard, Zine el Abidine Benaissa, and Emir Pasalic. DSL implementation using staging and monads. In Proc. 2nd conference on Domain Specific Languages, volume 35 of SIGPLAN, pages 81–94. ACM, October 1999. [SJ02] Tim Sheard and Simon Peyton Jones. Template meta-programming for Haskell. In Proceedings of the Haskell workshop 2002. ACM, 2002. [SMO04] Kamil Skalski, Michal Moskal, and Pawel Olszta. Meta-programming in Nemerle, 2004. http://nemerle.org/metaprogramming.pdf Accessed Oct 1 2004. [Ste99] Guy L. Steele, Jr. Growing a language. Higher-Order and Symbolic Computation, 12(3):221 – 236, October 1999. [Tah99] Walid Taha. Multi-Stage Programming: Its Theory and Applications. PhD thesis, Oregon Graduate Institute of Science and Technology, October 1999. [TH00] David Thomas and Andrew Hunt. Programming Ruby: A Pragmatic Programmer’s Guide. Addison-Wesley, 2000. [Vel95] Todd Veldhuizen. Using C++ template metaprograms. C++ Report, 7(4):36–43, May 1995. [vR03] Guido van Rossum. Python 2.3 reference manual, 2003. Accessed Sep 23 http://www.python.org/doc/2.3/ref/ref.html 2004. [WC93] Daniel Weise and Roger Crew. Programmable syntax macros. In Proc. SIGPLAN, pages 156–165, 1993. 58