The Design and Implementation of an Extensible System Meta-Programming Language
Abstract.
System programming languages are typically compiled in a linear pipeline process, which is a completely opaque and isolated to end-users. This limits the possibilities of performing meta-programming in the same language and environment, and the extensibility of the compiler itself by end-users. We propose a novel redefinition of the compilation process in terms of interpreting the program definition as a script. This evaluation is performed in an environment where the full compilation pipeline is implemented and exposed to the user via a meta-object protocol, which forms the basis for a meta-circular definition and implementation of the programming language itself. We demonstrate the feasibility of this approach by bootstrapping a self-compiling implementation of Sysmel, a static and dynamic typed Smalltalk and C++ inspired programming language.
1. Introduction
System vs Non-System Language
An important dichotomy in the classification of programming languages is on whether a programming language is meant for low-level close to the machine System programming or not. System programming languages such as C and C++ tend to have semantics with a direct translation towards unoptimized machine operations. These semantics allows a programmer using these language having a mental model. This cognitive mental model facilitates learning and debugging activities (Canas et al., 1994). It allows system programmer to have direct control of the machine, which facilitates writing high-performance code by avoiding unneeded abstraction layers such as having bytecode interpreter, JIT or garbage collector that introduces latency and non-determinism in execution times.
On the other hand, non-system programming languages such as Java, C# and Python, are languages that do not have a direct correspondence with machine operations. These non-system programming language facilitate the software development activity by providing abstractions such as automatic memory management, faster iteration cycles via interpretation, dynamic and duck typing, etc.. The presence of these abstractions increase the runtime cost of the program execution, and they also sacrifice the capability of having this close to the metal mental model. However, these abstraction are desirable because they improve software development productity, and they are used when execution performance can be sacrificed.
Language Impedance Mismatch
In multiple application domains, the simultaneous usage of a system and a non-system programming language is required. A high performance critical core is written in the low-level system programming language. The UI and non-critical performance sections are commonly written in higher-level languages which are typically used for scripting purposes. This also facilitates the extensibility of an application by people without a programming expertise, like an end user of a scriptable application such as a spreadsheet editor (e.g., VBA in Microsoft Excel(Walkenbach, 2010)).
The usage of at least two completely different programming languages is a common practice in the videogame industry. Commercial game programming is an activity where high-performance and productivity is important (Sweeney, 2006), and striving for a balance between them is a necessity. Game engines such as Unreal Engine(Epic Games, [n. d.]) and Unity(Haas, 2014), typically have a high performance core written in C++, and a high-level language like C# or Blueprint used for scripting and game design. Using multiple languages facilitates productivity in terms of reducing game testing and design iteration cycles by programming and non-programming people. However, the connection between two completely different languages such as C++ and the Blueprint, the visual scripting language used by Unreal, requires the maintenance or generation of wrapper code. These wrappers are typically maintained by hand or generated by a specialized offline tool that imposes restriction on the programming language features that can be used. They are some some general purposes tools like SWIG (Beazley et al., 1996) for solving this problem, but their usage might be precluded by project specific constraints.
Fixed Compilation Pipeline
The fixed compilation pipeline of C and C++ does not provide extension points in the language compiler itself. Accessing to the compiler data structures in an scriptable way inside might be an ideal mechanism for generating custom application specific reflection metadata required for supporting garbage collection, and automatic scripting language connection. Extensible compilation also facilitate metaprogramming, and the construction of DSL embedded directly in the host language (Renggli et al., 2010).
Unified Programming Language and Environment
We propose the design and construction of a programming language that can be used simultaneously in both context. We propose using this language as a script that defines how to build a program, whose execution constructs a Program Entity Graph. Different subgraphs can be obtained by tracing a subset of the program entities from an user specified root set. In the case of system programming, this root set is typically composed of only the main entry point. For a fully reflective environment, where the language is not used for system-programming, the root set is composed of the main entry and the global namespace object. By changing the set of program entities traced, we can compile down or up different features of the programming language, which facilitates adapting its usage for system and non-system program.
2. Sysmel language
2.1. Design
Influences
In this section we describe the design and implementation of Sysmel, a System Metaprogramming Language, with a design inspired mostly in Smalltalk, Lisp and C/C++. With the objective of unifying system and non-system programming, with an extensible compiler and metaprogramming facilities we take strong inspiration on these three important historical languages: 1) from Lisp, we take important the concepts of macros as function from AST into AST (Steele and Gabriel, 1996), meta-circular evaluation (Reynolds, 1972), and the Meta Object Protocol used for defining an Object Oriented Programming environment (Kiczales et al., 1991); 2) from Smalltalk, we take object-oriented programming via message passing, blocks as closures, the importance of a minimalistic syntax, and reflection as a mechanism for meta-circular definition; 3) and from C/C++, we take primitive types, pointers and direct memory access. We infer a concept of using static primitives types as a mechanism for definining translational. These type defined semantics facilitate a direct to machine operation mental-model.
Design Wishlist
From these influences we distill the following feature wishlist that we want to support in a single programming language, even if there are conflicting between them:
-
(1)
Minimalistic convenient to use flexible syntax.
-
(2)
Everything should look like an object at the syntax level.
-
(3)
Everything must be typed. Dynamic typing is realized via static typing.
- (4)
-
(5)
Block closures.
-
(6)
Arbitrary compile time evaluation support.
-
(7)
Lisp style macros which are functions from AST into AST functions.
-
(8)
Primitive types which are directly supported by the target machine.
-
(9)
Pointers and direct memory accesses.
-
(10)
Manual memory management support.
-
(11)
Optional automatic memory management via garbage collection support.
-
(12)
Compile time and optional runtime reflection.
-
(13)
The ability for extending and modifying all of the compilation pipeline stages.
Syntax in a postcard
The Sysmel syntax is strongly based on the syntax from Smalltalk, but there are additions and changes taken from C/C++ to facilitate supporting different programming styles. Sysmel syntax is minimalistic and it is based around the concepts of message sending, function application, and the construction of commonly used data structures. Everything is an expression and returns a value with a specific type. See Listing 1 for a postcard that illustrate the whole Sysmel base syntax. Higher-level syntactical constructs are realized via composing these base syntax elements, and by using metaprogramming techniques that manipulate the AST during compilation time.
2.2. Semantic Analysis Meta-Object Protocol
AST Protocol
The scanning and parsing stages can be done by using traditional approaches such as manually written recursive descent parsing, or by using more extensible approaches like parser combinators. Parsing produces nodes which conform to the Meta Object Protocol. The top level nodes respond to the #analyzeAndEvaluateWithEnvironment: message. This single message is used for simultaneous analysis and evaluation of the top-level parsed source code. The environment received by it is used for looking identifier values. Lambda nodes are evaluated into closure objects, which are composed of two parts: a capture vector, and a function definition object which contains analyzed arguments and body nodes. The analysis of the elements of a function definition are performed by sending the #analyzeWithEnvironment: message onto the argument, result type and body nodes. This message is responsible of returning a newly analyzed node where its value type is solved, and its children nodes are also analyzed recursively. Once a function definition is analyzed, it can be evaluated via two different mechanisms: 1) direct interpretation of the analyzed node, supported through the #evaluateWithEnvironment: message. 2) compilation into a bytecode or an IR that can be further optimized and compiled into machine code. We support these two mechanisms. In summary, the AST node semantic analysis MOP is composed of the following messages: #analyzeAndEvaluateWithEnvironment:, #analyzeWithEnvironment:, #evaluateWithEnvironment:, #compileBytecodesDirectlyWith: and #generateSSAValueWith:.
Extensible AST
AST nodes are defined as ordinary class instances inside of Sysmel. New AST nodes can be defined by just subclassing from ASTNode and then overriding the required methods. New AST nodes can be exposed in the language through macros. In fact, the local variable definition AST node is not present in the sysmel language syntax, but we expose it through two different mechanism: 1) macros like #let:with: and #let:type:with; and 2) the let metabuilder (See Section 2.3).
Function Application Analysis
Function applications are analyzed in two phases: first as an unexpanded function application, where the functional object can be a macro. The macro is invoked with the non-analyzed parameter nodes as arguments. The node returned by the macro is analyzed recursively. In the case of expanded function applications, the analysis of the application node is delegated onto the functional object type. This allows treating any object or value as a functional object. In the case of ordinary functions, its evaluation is performed by constructing an activation environment with the evaluated arguments. In other cases, the #applyWithArguments: message is sent to the functional object. One important optimization is always performed if possible. We define functionally pure functions in terms of observable external side effects, so we allow programmers to perform internal definitions through impure imperative mechanisms. For this reason any function can be marked as pure function. A pure function application that only uses literal nodes is always evaluated in compile time, and the application node is replaced by a literal node with the evaluation result. This application of referential transparency for pure functions is mandatory, and we use it for constructing derived type literals.
Message Send Analysis
In the case of message sends, there are also multiple analysis phases. First, the receiver node is analyzed, and the actual message send analysis is delegated onto the receiver node type. The receiver type first analyses the message selector. If the analyzed selector is a literal, then the corresponding macro or method is looked up through multiple dictionaries. If the found method is a macro, then it is expanded by receiving the receiver and argument nodes as parameters. If the method is not a macro, then there are two cases: if the method does not require dynamic dispatch (i.e., it cannot be overriden by a subclass), then the message send node is converted into a function application node which is analyzed recursively. If dynamic dispatch is required, then the remaining analysis of the message send is delegated onto the method type. If no method was found, if the receiver is not a dynamic type a semantic error is raised, otherwise a generic analysis is performed for the arguments of dynamic message sends.
Type System Protocol
The type system is another important side of the MOP. Types are also objects, and they are in fact instances of the class Type. The analysis of some AST nodes is delegated into specific types. This facilitates defining type specific translational semantics, binding message sends to methods statically, and defining type specific macros. We also use the type system for constructing pointer and reference types. C++ style references are used for constructing mutable variables. A reference type is constructed by responding to almost no message, except for #:= used for assignments, and address used for converting a reference into pointer. Pointers can be converted into references through the _ message. With these messages we support the semantics of the C pointer operators (&, *). The type system MOP is much larger than the MOP used for AST nodes. The following is a non-exhaustive list of some messages that are part of the MOP exposed in Type: #methodDictionary, #lookupSelector:, #analyzeAndEvaluateMessageSendNode:forReceiver:withEnvironment:, #analyzeMessageSendNode:withEnvironment:, #analyzeAndTypeCheckFunctionApplicationNode:withEnvironment:, #analyzeAndTypeCheckSolvedMessageSendNode:withEnvironment:, #ref, #pointer.
2.3. Metabuilders
The Builder pattern in Smalltalk is a common pattern for the construction of objects through successive message sends in a chain. We extend this pattern onto the meta-level by defining the concept of a metabuilder. A metabuilder is a builder that operates on syntactic language elements, and they can be seen as statefull macros. Metabuilders are instanced by invoking a macro function or macro identifier known as the metabuilder factory. Metabuilders are ordinary objects where their classes override the #analyzeAndEvaluateMessageSendNode:forReceiver:withEnvironment: and #analyzeMessageSendNode:withEnvironment: methods by delegating them onto the metabuilder instance. This delegation is always possible for the simultaneous analysis and evaluation case, and it is only possible if the metabuilder instance is present on a literal node for the AST analysis case. We use metabuilder for making higher-level syntactic looking constructs which look familiar to C++ and Java programmers. We also use them for hiding the actual manipulation of the underlying program entities which are also constructed through ordinary message sends. See Listing 2 for an example on how code can look like a different language when using metabuilders, even though the base syntax from Listing 1 is still the same.
2.4. Optimization and code generation pipeline
High-Level IR
The optimization and code generation pipeline unlike the semantic analysis is a much more traditional process. We perform code generation and optimization successive translation from the analyzed AST into different intermediate representations (IR). We first translate the AST into a high-level SSA based IR with a design inspired by LLVM (Lattner and Adve, 2004), where we model our base language operation semantics like function applications, message sends, local variable allocation (alloca), pointer and object slot load and stores. At this level we represent primitive type intrinsics as function calls. In our current implementation we perform some optimizations like constant propagation, control flow simplification and inlining. We are planning on having many more optimizations at this level.
Middle-Level IR
The high-level SSA form is translated into a mostly portable middle-level three address code IR which is also in SSA. The instructions of this IR is composed by tuples that contain a single machine primitive operation and its operands. We use this IR for performing lower-level optimizations like combining comparison with branches, and register allocation. We also compute the stack frame layout computation, and some phases of debug information generation during this stage before generating the next stage which is assembly.
Low-Level IR
Our low-level IR is assembly code. We use this IR for final machine code generation, and also for generating object files with included debug information. A subset of the program object graph is serialized into the data segment of the resulting object file, and references between objects are annotated with the required relocations. We are capable of generating platform specific relocatable ELF, COFF and Mach-O object file. Since we do not implement a linker, we rely on the standard linker provided by each operating system for constructing actual standalone executable programs.
3. Bootstrapping
Minimal C Implementation
Due to the circularity on the language definition, performing a proper bootstrap is a tricky and complicated process. We have attempted multiple times to construct and bootstrap this system. In our current implementation, we perform a phase0 compilation by constructing a minimal implementation in C. This minimal implementation takes care of parsing, base language semantic analysis, it uses the LISP2 compacting garbage collection algorithm (Cohen and Nicolau, 1983). To reduce bootstrapping development iteration cycles we implemented a register based bytecode, and a simplistic x86_64 JIT. The bootstrap environment uses a logical object-model where raw native pointers are not exposed. In this object model we have three kind of objects: immediates (encoded as tagged pointers), byte tuples, and pointer tuples. All of the objects can be seen as a tuple that contains a type (another tuple), an identity hash value, and the size of the tuple itself in bytes. With this simplistic object model we construct a complete Smalltalk style image environment in Sysmel. The base objects and types are defined by hand in C, the intrinsic operations are exposed as functions which have the name of the primitive annotated. We also implemented a minimal bootstrap parser and semantic analyzer in C.
Metastability Problems
When writing the actual semantic analyzer of Sysmel in Sysmel we had to be extra careful. Each time a new MOP method is implemented, its new definition is immediately being used for subsequent semantic analysis. Running onto different meta-stability issues when performing this kind of definitions is a big issue. We solved these problems by introducing the new definitions on a specific order, and by also anotatting the new method as functions that require an eager semantic analysis before they are installed as the new semantic analyzed and executable method.
Self Feeding AST and Program Graph
The traditional compiler bootstrapping process is performed via feeding the source code representation to subsequent versions of the compiler. In our case, we are compiling from the already analyzed in memory AST which is stored as part of the program entiy graph. The required program entities are traced from the global namespace object, and the main entry point function. The analyzed AST is used for constructing the SSA based IR and subsequent lower-level IR before generating machine code. The program entity object graph is serialized onto the data section of the object files, and references are annotated with relocations.
4. Limitations
Frontend not validated by bootstrap
One important limitation of our bootstrapping approach is that it only validates the quality of our middle-end and backend. The frontend of our compiler is not being validated by the bootstrapping process. Unlike a traditional bootstrap that starts clean from source-code, we start from already analyzed in memory AST nodes. We are skipping completely the source code parsing and semantic analysis stages during bootstrapping after the first phase. For this reason the frontend implementation is not being validated by the bootstrap.
Memory usage
Our bootstrapping process serializes a copy of the fully semantic analyzed AST and metaobjects that compose the program entity definitions. This incurs on a larger memory usage since a lot of compilation-only metadata is kept by the process. However, this same metadata might be used to construct development and debugging tools.
5. Related Work
Bootstrapping Reflective Systems
Embedding Languages in Pharo
Helvetia by Renggli is et al. (Renggli et al., 2010) is a framework for embedding languages inside of Pharo. We take inspiration inspiration from Helvetia for multiple elements such as the quasi-quoting of preceding by an extra backquote the standard Common Lisp operators. Our monadic parser framework is based on PetitParser by Kurs et al. (Kurs et al., 2013), another component used by Helvetia.
Bee Smalltalk
Bee is a Smalltalk implementation which is also completely defined in itself. Bee is also capable of constructing native executable through similar reflection based serialization process. Instead of relying on supporting C style primitive types for constructing the base runtime, the usey different mechanisms which they call “undermethods, underprimitives and inline nativization of bytecodes.” (Pimás et al., 2014)
6. Conclusions and future work
We described the central concepts behind the metacircular definition of Sysmel, a programming language designed for system and non-system programming. We also proved the feasibility for constructing this system by bootstrapping an self-compiling version of Sysmel capable of compiling and optimizing itself through three full self-compilation cycles.
In the future, we would like to continue improving our compilation and optimization infrastructure. We would like to perform benchmarks with a much more optimized version of Sysmel, on realistic applications to further validate the language. In this venue, it would also be desirable to validate the usage of Sysmel with people.
References
- (1)
- Beazley et al. (1996) David M Beazley et al. 1996. SWIG: An Easy to Use Tool for Integrating Scripting Languages with C and C++.. In Tcl/Tk Workshop, Vol. 43. 74.
- Canas et al. (1994) José Juan Canas, Maria Teresa Bajo, and Pilar Gonzalvo. 1994. Mental models and computer programming. International Journal of Human-Computer Studies 40, 5 (1994), 795–811.
- Cohen and Nicolau (1983) Jacques Cohen and Alexandru Nicolau. 1983. Comparison of compacting algorithms for garbage collection. ACM Transactions on Programming Languages and Systems (TOPLAS) 5, 4 (1983), 532–553.
- Epic Games ([n. d.]) Epic Games. [n. d.]. Unreal Engine. https://www.unrealengine.com
- Haas (2014) John K Haas. 2014. A history of the unity game engine. (2014).
- Hindley et al. (1969) Roger Hindley et al. 1969. The principal type-scheme of an object in combinatory logic. Transactions of the american mathematical society 146 (1969), 29–60.
- Kiczales et al. (1991) Gregor Kiczales, Jim Des Rivieres, and Daniel G Bobrow. 1991. The art of the metaobject protocol. MIT press.
- Kurs et al. (2013) Jan Kurs, Guillaume Larcheveque, Lukas Renggli, Alexandre Bergel, Damien Cassou, Stéphane Ducasse, and Jannik Laval. 2013. PetitParser: Building modular parsers. (2013).
- Lattner and Adve (2004) Chris Lattner and Vikram Adve. 2004. LLVM: A compilation framework for lifelong program analysis & transformation. In International symposium on code generation and optimization, 2004. CGO 2004. IEEE, 75–86.
- Milner (1978) Robin Milner. 1978. A theory of type polymorphism in programming. Journal of computer and system sciences 17, 3 (1978), 348–375.
- Pimás et al. (2014) Javier Pimás, Javier Burroni, and Gerardo Richarte. 2014. Design and implementation of Bee Smalltalk Runtime. In International Workshop on Smalltalk Technologies, IWST, Vol. 14. 24.
- Polito et al. (2015) Guillermo Polito, Stéphane Ducasse, Noury Bouraqadi, and Luc Fabresse. 2015. A bootstrapping infrastructure to build and extend pharo-like languages. In 2015 ACM International Symposium on New Ideas, New Paradigms, and Reflections on Programming and Software (Onward!). 183–196.
- Polito et al. (2014) Guillermo Polito, Stéphane Ducasse, Luc Fabresse, Noury Bouraqadi, and Benjamin Van Ryseghem. 2014. Bootstrapping reflective systems: The case of pharo. Science of Computer Programming 96 (2014), 141–155.
- Renggli et al. (2010) Lukas Renggli, Tudor Gîrba, and Oscar Nierstrasz. 2010. Embedding languages without breaking tools. In ECOOP 2010–Object-Oriented Programming: 24th European Conference, Maribor, Slovenia, June 21-25, 2010. Proceedings 24. Springer, 380–404.
- Reynolds (1972) John C Reynolds. 1972. Definitional interpreters for higher-order programming languages. In Proceedings of the ACM annual conference-Volume 2. 717–740.
- Steele and Gabriel (1996) Guy L Steele and Richard P Gabriel. 1996. The evolution of Lisp. In History of programming languages—II. 233–330.
- Sweeney (2006) Tim Sweeney. 2006. The next mainstream programming language: a game developer’s perspective. ACM SIGPLAN Notices 41, 1 (2006), 269–269.
- Walkenbach (2010) John Walkenbach. 2010. Excel 2010 power programming with VBA. Vol. 6. John Wiley & Sons.