Tag: Computing

  • FORTH, the minimalist programming language

    FORTH is a programming language, invented in 1970 by Charles Moore. It is very simple to implement and it can work with very small memories. Unlike BASIC for example, it is a very extensible language, reaching beyond the extensibility of languages like C, almost into the realm of LISP.

    Like LISP, FORTH is one of very few programming languages that does not use infix expressions. Instead, expressions are written in Reverse Polish Notation, like so:

    12 23 * 44 + .

    This is equivalent to the expression 12*12+44 in traditional languages. The fun thing is that FORTH does not need a parser in the traditional sense. When a number (like 12) is encountered, it is pushed onto the stack, when anything else is encountered, like ‘*’, the corresponding function is executed. For ‘*’, the executed code pops two numbers from the stack, multiplies them and pushes the result back onto the stack. The word ‘.’ prints the result of the expression (320 in this case).

    There are FORTH primitives to manipulate the stack, such as DROP, DUP, OVER and SWAP.. Traditional FORTH code tends to avoid local variables, but instead juggles multiple values on the stack. This stack juggling is sometimes hard to debug and it takes a lot of exercise to master. FORTH is simple, but not easy.

    Like the B programming language, FORTH has only one data type: the machine word, typically at least 16 bits, even on 8-bit machines. It can represent a signed integer, an unsigned integer or an address. Two adjacent words on the stack can form a double-length integer. If a FORTH system has floating point, floating point numbers are usually stored on a separate stack and they are a distinct data type.

    The Compiler

    Now look at a function definition:

    : PRINT-NUMBERS
      101 1 DO I . LOOP ; 

    The word ‘:’ is just another word that gets executed. When it is executed, the interpreter is switched to the compile state and a new definition (named PRINT-NUMBERS) is added to the dictionary. When in compile state, the interpreter does not execute the words it encounters, but instead adds the corresponding instructions to the newly compiled function. For numbers, it adds instructions to push the number to the stack when the newly compiled function is run. Some words, like “;” DO and LOOP have a special “immediate” flag and they are executed, even in the compile state. The word “;” leaves compile state and adds a “return” instruction to the newly compiled function. The words DO and LOOP take care to add the correct jump offsets, so the LOOP can jump back to the corresponding DO. There is also IF .. ELSE .. THEN and BEGIN .. WHILE .. REPEAT. These constructs can be nested, by, you guessed it, putting the branch origins and targets onto the stack while the compiler executes these words. No real parser is involved, the stack just does all the work.

    Threaded Code

    Especially in early FORTH implementations, the compiled code does not contain machine instructions, but addresses of functions to execute, mixed in with some literals and/or branch targets. This is called threaded code. Each FORTH primitive ends by loading the address and executing the next primitive in the threaded code. A non-primitive (a call to a compiled function) starts with a simple handler that pushes the threaded instruction pointer onto the return stack. This is a separate stack from the data stack, where values are stored.

    Even though threaded code is slower than compiled machine code, it was much faster than compiled BASIC or even the P-code that many Pascal compilers on 8-bit microcomputers compiled to.

    The 16 kB IDE

    An interactive disk-based FORTH system could work on machines with as little as 16 kB of RAM. Fig-Forth was just over 8 kilobytes in size and required a few kilobytes as disk buffers. It did not run under an operating system, but itwas the operating system. The traditional FORTH operating system did not use disk files, but 1 kB numbered disk blocks. Blocks containing FORTH source code, consisted of 16 lines of 64 characters each.

    You had a line-oriented editor for such blocks, so you had a complete interactive system within those 16 kilobytes.

    Later FORTH systems, such as F83 (by Laxen and Perry) ran under CP/M and used regular disk files under CP/M. These disk files however, consisted of the same fixed format 1kB source code blocks as before and the same type of line editor was used. Later FORTH systems, for example F-PC under MS-DOS, stored source code in traditional text files, which is now by far the most common.

    You needed maybe 48 KB of RAM to do a full recompilation of the FORTH system. FORTH is one of very few programming language systems that can recompile itself from source code on an 8-bit machine with 48 kB or less.

    Most FORTH systems have an assembler, but this is usually a postfix assembler, making the assembler instructions look backwards compared to what you are used to. Using the stack, the assembler can work without a parser in the traditional sense. Each opcode word collects the operands from the stack and stores the bytes of that instruction into memory.

    The Jupiter Ace

    In 1983, there was a very small Z80-based computer with hardware very similar to the ZX-81. This was the Jupiter Ace It had just 3kB or RAM, 1kB was only used for character bitmaps (you could write them but not read them back) and 768 bytes were allocated to the video RAM. So you had just 1kB of free memory to do useful things. Like the ZX-81, you pretty much needed a 16 kB RAM expansion to do more serious things. FORTH itself was stored in 8kB of ROM. It did not store source code in the traditional way, but instead the editor would decode the threaded code of a compiled function, so you could edit it. For this to work, the compiler had to store any comments inside the threaded code as well. Jupiter Ace FORTH crammed an incredible amount of functionality in that 8kB ROM, including floating point operations, that were far from standard. The Jupiter Ace is probably the only home computer with FORTH built in.

    Early use and Decline

    FORTH was widely used on minicomputers in the 1970s, primarily for embedded control. Minicomputers usually had a 16-bit address space and 64 kB RAM or less. It was not unusual for these systems to be multi-tasking or even multi-user. Having the compiler on the computer itself, made development and debugging easier. If you had written a small function to perform a specific operation, you could just test it interactively from the command line, without any need to write special test programs.

    FORTH was very much at home on early microcomputers as well, even though this market was dominated by BASIC. Compiled FORTH was so much faster than interpreted BASIC and the language was so much more extensible. As FORTH contained the editor, compiler and program execution in a single program, the turn-around time was usually much smaller than for traditional compiled languages, FORTH was a minimalistic IDE. With FORTH you did not have the long load times of traditional editors, compilers, assemblers and linkers.

    FORTH was a pioneering language on many new computer systems. It was often the first programming language that could work on it.

    Even though it was never a real mainstream computer language, FORTH did had a large niche market well into the 1990s, especially for embedded applications. But also some games and desktop applications were written in it. On some RISC CPU architectures, such as PowerPC and SPARC, FORTH was the basis for the boot firmware. Every PowerPC Macintosh or Sun SPARC workstation contained FORTH in its ROM.

    Even though FORTH is still available for nearly all modern microcontrollers, it is very much a niche language today. Interactive debugging of C programs on microcontrollers has much improved over the last few decades. Cross compilers for C and C++ are everywhere and they are freely available. Every new CPU architecture has gcc and LLVM ported to it before the first silicon is available, so FORTH’s role as a pioneering language is largely a thing of the past.

  • The rise and fall of inheritance

    The first object oriented language was Simula-67, an Algol-like language, specifically designed to run simulations, for example of cars waiting for traffic lights. Traffic lights, cars, crossings and roads could each be represented by objects, each with their own functional behaviour. You could design the functional behaviour of each of your objects and as a whole, they would simulate the traffic situations you wanted to analyse. It would also be extremely convenient to add special kinds of cars (or crossings), that could reuse most of the behaviour of their regular counterparts. This would later be known as inheritance.

    Smalltalk would be the next object oriented language. Objects were modelled to literally send messages to other objects, Objects were modelled as little servers that could receive commands from anyone and then execute those commands and return the corresponding results to the sender of the original command. Method calls were not seen as a special type of function call, bound to an object, but as messages representing commands.

    Everything in Smalltalk is an object, including numbers. A number would receive a ‘+’ command (followed by another number as a parameter) and then do the addition and return the sum as yet another number object. Smalltalk was integrated with a GUI and GUI objects turned out to be very suitable for the object-oriented model, including inheritance.

    C++ came in the 1980s. In the 1990s, no programming language would be taken seriously if it did not embrace the object oriented philosophy. Languages like Pascal, Ada, Perl and Visual Basic would get object oriented extensions. New languages like Python and Java would be designed as object oriented languages from the start.

    Data Structures and Methods

    Early programming language like FORTRAN or Algol had only a very limited set of data types: scalar values like integers or reals, plus arrays of these scalar values. A date, consisting of a day, month and year needed three separate variables to store it. If you needed an array of dates, you would need three separate arrays (or maybe a multidimensional one). .

    COBOL introduced grouping of data items into records(one of the few good things that we owe to COBOL). This way we would group the name, address and data of birth of a person into a single data structure. Niklaus Wirth proposed an Algol extension in the 1960s, called Algol-W. This Algol extension contained a record type, containing named fields, each with its own type. This feature was of course carried over to Pascal (designed by the same person).

    In order to have an object oriented language, you need a way to group related data items into a single structure. These records are called “struct” in C and related languages.

    Languages like Zig and Rust allow you to add methods to data structures. In essence, a method is just syntactic sugar to associate a function call with an object. For example we can now write my_object.my_method(); instead of my_method(my_object);.

    Ada-95 has object-oriented features, but no method call syntax. Zig has method call syntax, but no inheritance (or even interface types). So method call syntax is neither required nor sufficient to make a language object oriented.

    Inheritance

    Sometimes there are different variants of an object type. In a university database, a person can either be a student or a staff member. Students and staff members share some data members and methods, but some data members or methods are different. Pascal allows variant records. Part of a record can contain different fields, depending on a ‘tag’ field. In C you can achieve the same effect by defining one of the fields in a ‘struct’ as a union. Each of the union variants can be its own ‘struct’ with the per-subtype fields. Each method has to select the appropriate subfields, depending on a tag field, which is included in the main (non-variant) part of the record or ‘struct’..

    The problem with this approach is that all variants have to be baked into the record or ‘struct’ type from the start. All variant behaviour in methods is inside ‘switch’ or ‘if’ statements inside these methods. Adding more variants to the record type requires you to edit the source code of the data type definition and all the relevant methods. Variant records or tagged unions are a viable alternative to inheritance in many situations.

    True object oriented languages however, support inheritance. You can define a type as a subclass of an already existing object type. The subclass type inherits the member variables (data fields) and methods (associated functions) of the base class. Then the subclass can add more member variables and more methods to it. Plus it can replace existing methods with its own, where an overridden method of a subclass can call the corresponding method of the base class as well. So in the university data base we can have a base class “Person”, with subclasses “Student” and “StaffMember”. The Student subclass gets additional information about completed courses and methods to manipulate these, the StaffMember class gets additional information about salary. The definitions of StaffMember and Student can be in separate files, without requiring any modifications to the definition of the Person class.

    If you implement this type of inheritance in a compiled language, variables of the base object type and each of the subclass types have different sizes. Therefore you cannot have a variable that can hold an object type and each of its subclasses. You can only have variables that contain pointers to such objects, where each object was allocated separately, for example on the heap. Each object variable has to contain a pointer to a table of function pointers (often called ‘vtable’). Those function pointers correspond to all the methods a specific subclass has. Although the overhead is nothing compared to that of interpreted languages like Python, there is overhead nevertheless, caused by these levels of indirection.

    Although inheritance can bring code reuse, it is often the case that behaviour of a single object gets spread across many source files, each corresponding to a different level in the class hierarchy.

    Inheritance can be added to languages like C or Zig, but it requires manually implementing the vtable mechanism and careful casting of object pointers between the base class type and the relevant subclass type. The data structure of a subclass contains the complete data structure of the base class as its first member, so the pointer to the subclass type can be cast to a pointer to the base class type and vice versa. The first implementation of C++ was a preprocessor that converted C++ code to regular C and the generated C code would implement inheritance in exactly this way.

    Multiple Inheritance

    In some case, an object can be a special case of multiple things. In the category of 2D-shapes, a square can be either a special type of rectangle (with equal width and height) or a special type of rhombus (with right angles). In those cases you might want to inherit from two or more base classes. But if the classes Rectangle and Rhombus each have a draw method, which one to pick for the Square? This is sometimes called the “diamond problem”, due to the diamond-shaped class hierarchy (base class at the top, two subclasses at the sides and multiply-inherited sub-subclass at the bottom).

    C++ and Python both have multiple inheritance. This adds much complexity to the language and it makes it harder to understand what the resulting object really is and how it behaves. In C++ the multiply inherited class gets two instances of the top base class, It is strange for a 2D-shape to have two distinct area member variables.

    There are reasons why many object oriented languages like Java and Smalltalk restrict themselves to single inheritance

    Abstract Base Classes

    In the world of 2D-shapes, you can have triangles, rectangles, circles and hexagons, which can all be subclasses of a Shape base class. But you would never have ‘just’ a Shape object without specifying what kind of shape it is. The Shape base class could have methods like ‘draw’ or ‘compute_area’ and a function can take as parameter a reference to s Shape (which can then be any of the subclasses). But we would never instantiate a variable of just a Shape, only of the many subclasses this base class has. The Shape base class would specify the existence of the ‘draw’ and ‘compute_area’ methods but it would not implement them, only the subclasses would.

    This pattern is called an abstract base class and it occurs many times in object oriented designs..

    Interfaces

    If we have an abstract base class without any member variables or implemented methods, we have in fact an interface.

    An interface is basically just a bunch of methods that any object implementing this interface must have. We can have a pointer to any object that implements the interface and then we can call methods on that object.Under the hood we need vtable pointers along with each reference to an interface object.

    In case of an abstract base class, we can inherit from this base class and implement all the required methods. This way we implemented the interface and we can have pointer to an object of the abstract base class that can later be used to call the methods.

    But interfaces can also exist without proper inheritance. This is what many modern programming languages have. What inheritance does not provide, can often be implemented by composition. Suppose we have a database of all persons at a university. We can implement an interface called Person that defines all common methods. We can them implement a BasePerson structure with all fields that should be associated with any person, like name, address and date of birth, along with common functions. We can have the data types Student and StaffMember, each of which contains BasePerson as one of its fields and implements the Person interface.

    The nice thing with interfaces is that a data type can implement several interfaces without too many problems.

    Modern Languages

    Most modern languages do not implement inheritance, let alone multiple inheritance. Go and Rust rely on interfaces (called ‘traits’ in Rust). Using interfaces and composition, you can get basically all the stuff done that object oriented programming could do, without many of the drawbacks.

    Of the C-replacement languages, C3 does implement interfaces, while Zig and Odin require you to do everything by hand.

    Object oriented programming seems to have been past its peak. Some of the good things will remain and where it is useful, it has its place. But making everything an object just for the sake of it, is no longer considered good practice.

  • Error handling in programming languages

    There are many ways in which programming languages may support runtime error handling. Languages like Python, C++ and Java support exceptions, but this feature has become less popular over the last few years, as it leads to problems with freeing up resources. Rust and Zig require you to handle errors at each call level and they allow you to simply return any error to the calling function with minimal additional code.

    Some early BASIC interpreters would simply abort when they detected a runtime error. Turbo Pascal would do the same. It came with a mini-spreadsheet. While it did check for division by zero and avoided that error, a floating point overflow error would abort the program without any opportunity to save your data that you entered. Even GW-BASIC had an ON ERROR GOTO statement, which allowed you to keep the program running if an error occurred.

    Error Detection

    There are two ways a runtime error can be detected:

    • A hardware trap. For example, most modern CPUs would trap on accessing memory addresses out of range and on division by zero. Some could even trap on integer overflow. On Unix systems, these hardware traps would cause your program to receive a ‘signal’. Your program would be aborted by default, but you can handle the signal by calling a handling function when it occurs.
    • A check in software. Most I/O functions return a result code. For example if you try to open a non-existing file, your OS detects in software that the file does not exists. The open system call returns an error code. The C library function fopen will return a NULL pointer instead of a pointer to a valid FILE data structure. A hardware trap is never involved in this case.

    Early Strategies

    Many versions of BASIC had an ON ERROR GOTO statement. The error handler at that line would then try to fix the error condition and continue the program with RESUME or RESUME NEXT. The latter variant would skip the statement that caused the error, instead of retrying it. Instead of restarting, you could try to end the program more gracefully, possibly allowing the user to save unsaved data first.

    C and Unix use the signal functions, that allow you to handle errors like out-of-bounds memory access and division by zero. C also has the infamous setjmp/longjmp functions. These functions implement a very crude way to handle exceptions. The function setjmp stores the contents of registers in a jmp_buf structure, including the current value of the stack pointer and the return address the setjmp function would return to. After this, setjmp returns the value 0. The function longjmp would restore the registers from the jmp_buf data structure and jump back to the point where setjmp would have returned to, but now returning a non-zero value. Every function called after setjmp could later call longjmp and return to the location of setjmp. The longjmp function could be called by a signal handler to throw the program back to a defined state, from which it could safely recover.

    I/O errors in C would typically be handled by error results from function calls and each function had to propagate the error back to its caller.

    There is also the possibility not to disrupt the control flow in case of an error, but to store a special result value like NaN or Infinity. This is typically used for floating point computations. The program is allowed to continue to run and at the end, some results are invalid, but others may be valid and useful.

    Exceptions

    Some languages implement exceptions, which act a bit like the setjmp/longjmp functions in C, but now there is a nicer language syntax around it. Plus you can have the exception handled at multiple levels. For example, if function A catches an exception and it calls function B, which also catches that exception, then B would handle the exception when it occurs. After B returns, the exception would again be handled by A.

    In a simplistic implementation, when an exception occurs, the registers like the stack pointer are restored to the point where the exception was last caught. Any memory allocated at intermediate call levels would not be freed and would likely be leaked, And I’m not even mentioning other resources like open files, network connections and windows in a GUI. A bit more sophisticated implementation would visit each stack frame in turn and free all memory that was known to need freeing when that function returned. Garbage collected languages would not free the memory anyway and would leave it to the garbage collector at a later time. But even they would typically not release other resources.

    Handling an exception properly, under all circumstances, is very hard. It is therefore a much less popular feature than a few decades ago. Exceptions are still great when you want to handle error conditions that are typically detected by a hardware trap, such as integer division by zero. Or for integer overflow in general.

    It may be feasible to add error handling code to each I/O function call, but it would be very unwieldy to add this to every arithmetic expression that might overflow. Exceptions may still be a good solution for these.

    Error Propagation

    Rust and Zig use error propagation instead of exceptions. There are special result types that are logically the union of an error result and a regular result. So a function can return either an error result or a regular result. Function returns are checked at every call, but there is easy syntax to just return from the function early and just propagate the error result to the caller. Function A calls function B (that can return an error result), and function B opens a file. With some convenient syntax, function B can just return the error to A when the file open call returns an error result. In these languages, each function always returns to its caller and there is no magic stack unwinding.

    Zig (and C3 and Odin) have the defer statement to specify that some statements have to be executed before returning from the function (or leaving another scope), regardless of how the function returns. These defer statements will be executed even in case of error propagation. These defer statements are typically used to free up resources that were allocated during that function.

    Conclusion

    Error handling is complex and no matter what strategy you use for it, error conditions are always the least tested aspects of a program. Whether it is acceptable to abort the program when a serious error occurs, depends on the situation. Embedded control systems often do not have the option to abort and they must keep the system under control under all circumstances. Your fly by wire system is never allowed to drop the plane from the sky. Of course a single program could abort in this case (and probably be restarted), as long as a fallback system is in place. Coping with error situations in cars, planes and nuclear power plants is an engineering discipline of its own.

    Programs like text editors or spreadsheets, that allow a user to enter large amounts of data, must be designed to allow that data to be saved. Losing a few hours worth of spreadsheet data may not be as bad as a crashed plane, but it is certainly rude to lose that data without putting up a fight to save it.

    Ignoring an error is never a good idea. Checking inputs to ensure that buffer overflows cannot happen and integers cannot overflow, is generally a good idea. It is generally better to refuse to complete a transaction because it fails range checks (even though the values are valid after all) than to complete it with a silent overflow and a bogus result.

  • On the extensibility of programming languages

    In the early days of PC compatible computers, MS-DOS came with GW-BASIC, a BASIC interpreter with many built-in commands for graphics, event handling and file access. .We had commands like:

    1000 ON KEY(1) GOSUB 100: REM call a subroutine when F1 key is pressed
    1010 LINE (100,200)-(300,400): REM DRAW a line on the screen
    1020 OPEN "myfile.txt" FOR INPUT AS #1 : REM speaks for itself
    1030 PRINT USING "####.##";A : REM print number with specific formatting
    1040 PRINT #1,"The numbers are:";A,B: REM print to a file, different parameter separators.
    

    Each of these command had its own special syntax. Apart from ON KEY we also had ON PEN to specify an action for the light pen, a truly forgotten device, that nevertheless got its own keyword in the syntax. The parentheses around the end points in the LINE command were part of the syntax and the “-” did not mean subtraction, but it was there suggest from..to. The PRINT command had an optional USING part, but also an optional ‘#’ part to specify a file and different separators between parameters: “;” to specify that the next parameter had to be printed immediately after the previous one and “,’ to specify that it had to be printed at the next “tab stop”.

    At the same time, GW-BASIC did not allow you to create new procedures, only the humble GOSUB, which is in some respects below the level of assembly language. QBASIC did add named subroutines, but calls to them did not look anything like the built-in commands.

    COBOL also has a very large number of commands built into the language, each with its own syntax.

    These languages represent one end of the extensibility spectrum. They came with many features included, but what you got, was all you would ever have.

    Suffice it to say that no modern programming language comes with built-in commands to draw lines and circles or to bind keypress events to functions. If you need this type of functionality, you can load a library for it.

    Pascal

    Pascal is a nice step up from the horrors of GW-BASIC. It lets you define your own functions and procedures and even your own data types. But if you look closer to Pascal, you will find that built-in procedures can do many things that user-defined procedures can’t:

    • The read and write procedures can take parameters of many different types. The same is true for some other built-in procedures, like reset, rewrite, new and dispose.
    • The read and write procedures can take a variable number of parameters.
    • The write procedure has special formatting syntax with colon characters to describe how a number should be printed. like write(a:12:3);

    Pascal clearly has its I/O functions baked into the language, using many privileged procedures.

    Small versus Big Languages

    Almost any modern programming languages has its I/O functionality outside the core language. Any I/O functionality in the standard library, could also be implemented in a library you could implement in the language itself. Programs in those languages can run on embedded systems that do not have terminal or file I/O, but very different I/O functionality instead.

    C is a small language, but flexible enough to write its own I/O library in C itself. C. Functions from stdio.h, such as fopen and fread are ordinary functions that you can just write in C. Even the printf function, with its variable number and types of parameters, can be written in C, though it requires some trickery.. Better yet, heap allocation functions like malloc and free can just be written in C.

    If you write code in C and don’t call any functions outside of your program, the resulting program will include very few external functions when it is linked. If you are on a RISC system without integer division, you get an integer division function linked with your program and you may get memcpy or its equivalent when you assign struct variables to one another. Software floating point functions are another thing you might get if your CPU has no hardware floating point and your program uses floating point. You can use C to write operating system kernels, boot loaders, embedded firmware, real-time applications and more.

    Zig, C3 and Odin are similarly small languages that don’t force you to link with an excessively sized runtime library..

    On the other hand, if your language has a garbage collector, your compiled programs will depend on a large piece of external code, the garbage collator itself. It takes away the control that you need when you write an operating system kernel or a real time embedded application.

    C does not have all the extension features that exist in programming languages. It has no operator overloading, no generics, no true modules and the preprocessor macros are very crude by today’s standards. A language like C3 has more extensibility features in that respect, even though this language is still not very big.

    Operator overloading makes a language more complex, but at the same time, it allows you to add data types like matrices, vectors, complex numbers and arbitrary size integers later, complete with algebraic operators, so we do not have to choose between including them in the base language (like Odin does) or not having the convenience of algebraic expressions with these types at all.

    Ada, C++, Java, Go, Rust and D are much larger languages than for example C. Some of these have a garbage collector, Rust achieves memory safety using extensive compiler checks. Sine if these languages support exceptions, so an errors can cause the program to unwind the stack through many call levels and then return to the level at which the exception is caught.

    While perl has hash tables and array lists as built-in types, most modern languages have the extensibility features to define them in their standard libraries, without them being necessary in the core language. Even a small language can have these features at its disposal, if it has enough extensibility features to add them.

    The Upper End of the Spectrum

    In terms of extensibility, LISP is probably the best language. While some languages brag about object-oriented features, LISP has always had the ability to be extended in a way to add them.

    LISP has macros that can rewrite programs in an arbitrary way. You can add new control structures, like more advanced loops or switch statements. Very few programming languages have that ability.

    LISP is one of very few languages that has no infix expressions. That makes its parser very simple. Once parsed, the LISP program is just a bunch of nested lists, a direct representation of the parse tree.

    A honourable mention goes to FORTH, Like LISP, it sacrifices infix expressions, but unlike LISP, it does not parse an expression into a nested list, instead it directly executes each token and lets the stack do all the work. While LISP uses prefix notation with parentheses to indicate nesting, FORTH uses postfix notation without parentheses. FORTH can also build new control structures. FORTH taps into some of the unique extensibility features that LISP also has, but its implementation is considerably simpler. It could work on machines with very little memory and it would execute much faster than interpreted BASIC or LISP.. I should devote s separate post to this little language.

  • Macro Systems for Programming

    The C preprocessor may be the worst macro system for any programming language. It is complex enough to seriously obfuscate programs with it, but it is not Turing complete, so you cannot meaningfully implement loops in it. The preprocessor supports conditional compilation using #if and #ifdef, but these conditionals cannot appear in the expansion of a macro. As the preprocessor is a separate pass from the C compiler itself. preprocessor conditionals cannot contain values that are only known to the compiler (and not to the preprocessor), such as the size of a data item, enum values or the value of an object declared ‘const’. So for instance you cannot do something like this:

    #if sizeof(struct1) != sizeof(struct2)
    #error "struct1 must be the same size as struct2"
    #endif

    Some tricks exist to do a similar check at compile time, like:

    int unused_array[-(sizeof(struct1) != sizeof(struct2)];

    This declares an unused array of size 0 (permitted) i n case the sizes are equal, but of size -1 (compile time error) if the sizes are not equal.

    Header files are not real “module interface” files, but hey are textually included by the preprocessor. We need the “include guard” pattern to prevent them from being include more than once, like this:

    #ifndef MYHEADER_H_
    #define MYHEADER_H_
    ...
    
    #endif

    If you define a macro expression that looks like a function call, you need to put parentheses around any parameters and around the expression itself. If the macro expands to a sequence of statements, you have to wrap in in a do .. while(0) construct. This is annoying and once in a thousand times you make a mistakes and another nasty bug is born.

    If macros can’t do what you want, you could use a more powerful macro processor, such as m4, or you could use a dedicated program to generate the C files for you. Think for example of the ‘yacc’ program to generate parsers. Apparently there is no standard way to convert a binary file into a C header file containing an initialised constant array with all the bytes of that file. If have written scripts to do that just too often. If these arrays are large, the memory consumption of the C compiler is usually terrible.

    Generics

    In C++ you have templates. These are useful for generating data structures that may contain different data types as their payload. So you can instantiate a hash table that contains integers or one that contains “struct foo” items. In fact the std::map template is generic over two types: one for the keys and one for the stored values. You can declare a map like this:

    std::map<std::string, u32> mytable;

    The parameters are in angle brackets and they are all handled at compile time. Templates perform some tasks that macros could do, but they are part of the compiler itself, not of some separate preprocessor. In C++, it is pretty easy to make use of pre-defined templates, but it is usually very hard to create your own templates.

    Other languages, like Ada, also have generics. The Ada programming language has no macro preprocessor, but it does have powerful generics.

    Other Languages

    Many programming languages do not have any macro system or preprocessor at all, not even generics. Languages like Pascal, FORTRAN and BASIC fall into this category.

    Python has no micros or generics either, but as it is dynamically typed, you do not need generics to implement generic container classes. You can use operators like ‘+’ and ‘<‘ in a function and they will automatically just work for any data types that support these operators, making the function a generic one.

    Systems like LISP support macros that allow you to build arbitrary LISP programs under program control. This way you can for example add totally new control structures to the language.

    Some programming systems have a clear distinction between “compile time” and “execution time”. When the C compiler runs, it translates the C program to assembly language, but it cannot itself run C code. The compiler can run on a system different from where the compiled code is to run. You can run a C compiler on an x86 PC and let it generate code that will run on a Raspberry Pi Pico (ARM Thumb 2 core). With interpreted languages, like Python, you can run Python code at load time. When you import a Python module, it will usually add new functions and/or classes to your program, but it could also run code immediately when it loads. It could build a complex data structure when it loads. The Python interpreter can always run Python code.

    FORTH has an interesting combination of features. When it compiles new functions (colon definitions), it converts FORTH source code into threaded code, which can later be interpreted. When not compiling new functions, it just runs your FORTH code. In FORTH, you can extend the compiler, so you can define new control structures. This gives you the same flexibility as LISP macros. Extending the compiler is done by marking some FORTH functions IMMEDIATE, so they will execute, even while a new function is being compiled. By default, the compiler would just add the function’s address to the threaded code.

    Rust has macros. For example println! is a macro. Using those macros is fairly easy, but defining new ones is a hard job, requiring you to learn much about compiler internals.

    Assemblers have had macros for a long time. A macro could expand to a sequence of instructions and depending on the exact assembler you were using, you could give the macro parameters (for example specifying a register to be used on some of the instructions), the macro expansion could contain conditional assembly or even loops and the macro expansion could contain local labels, so the labels inside different instances of the macro would not conflict with one another.

    Zig

    Zig is the latest development in macro technology.,First of all, Zig is a true compiler. It converts your source code to compiled machine code, to be executed later, possibly on a completely different machine. Therefore it has clearly distinct compile time and run time phases. But even at compile time, the compiler can run Zig code in your source files. Zig replaces any dedicated macro language that programming languages may have. The build script is Zig code, generics are implemented using Zig code. This can work because data types are values that can be parameters and returned results of compile time Zig functions. Zig code can generate arbitrarily complex data tables at compile time.

    One example of compile time behaviour in Zig: the format strings for formatted printing are parsed at compile time and calls to specific output functions are generated, depending on the desired format and the types of the parameters passed to that function.