Int. J. Man-Machine Studies (1974) 6, 335-359
An Easily-implemented Language for Computer Control of Complex Experiments T. R. G. GREEN AND D. J. GUEST
M.R.C. Social and Applied Psychology Unit, Department of Psychology, University of Sheffield, U.K. (Received 8 June 1973) The main features of a general-purpose experimental language are described followed by an account of its implementation. The nature of the language allows the implementation to proceed in three stages, of which only the first requires assembly-code programming, so that it is cheap and easy to set up. The aim of the paper is to provide a guide for those wishing to create similar systems, and the techniques required are therefore referred to clear published accounts or are described in detail. A concluding example demonstrates the programming of an algorithm for parsing with respect to context-free phrase-structure grammars. Introduction There has been rapid growth in the acquisition of small digital computers by departments of psychology for control of experiments. Recently the trend has been away from the purchase of very small machines towards ones with core sizes of 8K × 16 bits or more. These machines are large enough to raise serious interest in the possibility of using a more convenient language than assembly-code for programming experiments. Assembly-codes, as is well known, are not easy to use without great familiarity; they are also difficult to debug, so that unless the program is very simple or the programmer very experienced the development time is considerable. As a communication medium, they are non-starters--no-one would attempt to describe a clever procedure in assembly-code if he had any other option. A number of special-purpose languages have appeared which are intended chiefly for the control of particular types of experiment: ACT, devised by Millenson (1971), is an excellent example; others are described in Apter & Westby (1973). These languages are a great improvement on assembly-codes and because they aim for a limited objective, such as the control of animal discrimination experiments, they are not too demanding to implement. 335
336
T. R. G. GREEN AND D. J. GUEST
Unfortunately, for the same reason they cannot readily be generalized to quite different areas, such as experiments on artificial languages. One must choose between a language which excels in a narrow area, like ACT, and a language which covers a wider area less well. T h e authors take the view that the desirable language for controlling complex experiments would be similar to conventional scientific languages such as FORTRAN and ALGOL, but would be equipped with list-processing features and be able to use words as data items when required. Routines to control the peripherals needed for experiments, e.g. relays, lamps, and clocks, would be incorporated. Such a language would have three advantages: occasional programmers in FORTRANor ALGOLwould pick it up easily; many types of experiment could be controlled with equal facility; and it would be highly legible, so that it could be used as a communication medium. (We should like to make it clear that we are not proposing standardization. The wide variety of peripherals in use would make that impossible, and in any case it is unimportant except for huge number-crunching programs.) The desirable language would also be compact enough to be implemented on current machines as a load-and-go system, and above all would be cheap enough to implement to make it feasible for the slender programming resources usually available. One can get surprisingly close to that ideal. In use at this Unit is a language which meets many of the requirements; its major departure is that it does not incorporate real-number arithmetic, since floating-point packages are bulky. However, not many experiments actually need real numbers. The methods adopted make it very easy to implement, and the first version was in operational use after three months' work by two people. A rich variety of conditionals and loops is provided, these being the characteristics useful for experiments--in fact, there is more variety than in ALGOL60. High-sounding acronyms are fashionable and the language is therefore called GLUE: Generalized Language for Universal Experimentation. It is related to POP-2 (Burstall, Collins & Popplestone, 1968), a language designed for artificial intelligence work, although we sacrificed a good deal of elegance and power to write the compiler easily. The aim of this paper is to provide a cursory account of the main facilities of GLUEtogether with a sufficiently detailed account of the method of implementation to allow other workers to adopt the same techniques. It is our belief that many installations would be able to afford the investment in programming and would benefit from the results of using a tailor-made language, but are not able to do so at present because of the absence of a general guide to the techniques.
A LANGUAGE FOR EXPERIMENTS
337
The paper is not intended as a programming manual. Most of the following account is intended to be comprehensible to those who have some knowledge of either ALGOL or FORTRAN, and explanations of concepts familiar in those languages are not provided; however, the later sections will require a little familiarity with the ideas of assembly-code programming. GLUE is implemented at Sheffield on a CTL Modular I computer, which uses a 16-bit word size with an unusually short address field of 8 bits, giving a very large repertoire of basic orders. This complicates the estimation of the core size required. The main facilities could certainly be realized in most machines with 12K x 16 bit core. The implementation does not use backing store at present, and no comments have been made on its use. It would be possible to trade core for backing store; the desirability would depend on the access time, the required execution speed of the compiled program, and so on. Some discussion of these matters can be found in a very clear paper by Bobrow & Murphy (1967) and subsequent papers in that journal. At the time of writing we are starting to consider how a disc could be incorporated.
Overview GLUE is heavily oriented towards a configuration comprising paper tape, an interactive console, whatever experimental peripherals are in use, such as switches, relays, analogue-to-digital converters and visual displays, and core store. It is taken that sufficient core is available for the whole system to be resident. Like scientific languages, GLUEhas variables which can be assigned values, but these values can be not only numbers (integers) but also strings, such as APPLEPIE, and lists. These are called simple data objects. It also has arrays, which may be dynamic and multidimensional, and functions; these are called compound data objects. Functions are the equivalent of ALGOL procedures or FORTRAN subprograms; they may or may not have a result, in the sense that "sin(X)" has a result but "print(X)" does not. (There are also label objects, required for compiling GOTOs; see section, Implementation.) In a language like ALGOL, a particular variable can only hold one kind of data object--for example, a boolean cannot be assigned an integer value. This is not the case in GLUE; any variable is capable of holding any kind of data object. In consequence, it is not possible to know at compile-time what the contents of a variable will be at run-time, and in order to discover at runtime what a variable holds, each data-object is marked with a tag identifying its type. This gives a good deal of flexibility in programming, and incidentally
338
T. R. G. GREEN AND D. J. GUEST
removes the need to do exhaustive compile-time checks, thereby simplifying the compiler. GLUE uses an explicit "stack" mechanism; for various reasons, this is a corollary of the previous point. A stack is a queue in which the last object put in is the first object removed, used for communication between functions. A function for doubling a number takes its parameter from the stack and leaves its result on the stack. Sometimes it is useful to leave more than one object on the stack as a function's results, and this is easily accommodated. For the benefit of ALGOLprogrammers, parameters in GLUEare called by value rather than by name. GLUE has no block structure corresponding to ALGOLblock structure, and no main program in the FORTRANsense, because it is interactive. The concept of "interactiveness" is usually unfamiliar. In its simplest use, GLUE is a glorified calculating machine: if you type P R I N T ( 2 + 2 ) ; the answer is immediately printed. Every command typed is immediately obeyed when its terminal semicolon is reached, which means that the user has very close and simple control over his program; he can run different bits, examine the values of variables, and redefine parts. He can also type complete programs in at the console, but we normally use paper tape input or a display editor. The interactiveness concept has further implications. For example, if commands are obeyed as soon as typed, it is not possible to have a stored "main program" in the sense of ALGOL or FORTRAN. The only way to have program stored is by writing a function definition. A function is a set of commands attached to a name, and the commands are executed as soon as the name is typed. Function definitions may not include nested function definitions. Therefore a GLUEprogram, because it is interactive, consists of a structureless concatenation of function definitions. It has no beginning and no end, because it has no stored main program in which the functions are embedded. One does not "execute a program" in GLUE; one calls a function, by writing its name. It is immaterial whether that function was the first or the last to be defined, or whether it in turn calls other functions. Now it so happens that one function, in no other way particularly distinguished, is the function F U N C whose execution involves reading and compiling a function definition. When F U N C terminates, GLUE goes back as always to wait for another command to obey. Operationally there is no difference between calling F U N C and calling any other function, and so one can compile a bit and try it, and then compile a bit more. From the user's point of view, interactiveness has two advantages. There is no tedious "two-pass" system in which a program is first semi-compiled on to paper tape, which is then loaded and run as a second pass. Knowledge of
A LANGUAGE FOR EXPERIMENTS
339
results is immediate and alterations or additions are quick. Second, debugging can proceed in easy stages: test one function, okay, test another, eventually test the whole. From the compiler-writer's point of view, there are again two advantages. Having neither ALGOLblock structure nor FORTRANcommon blocks eases the problems about data storage. Second, and here is the secret, the compiler has access to itself. We have already remarked that GLUE can use strings as data objects: afortiori it can treat program text as data. To implement GLUE, a compiler is written in assembly code for an elementary language which we shall call GO. Extensions to GO are written in GO to produce G1, a more powerful language; these extensions include devices for rewriting conditionals in terms of simpler conditionals, for example. (See p. 349 for details.) This technique, known as "bootstrapping", means that not very much assembly code needs to be written, with a consequent saving in debugging time. It can only be used, however, in a system which has both the compiling routines and the execution routines simultaneously resident, which is always the case for an interactive system. Bootstrapping causes slow compiling, because assembly code programs run faster than their high-level counterparts. In GLUEwe actually use multiple bootstrapping, because extensions to G1 are written in G1 to produce G2, the user language. Speed of compilation is not normally of much significance, however, in the experimental installation. By using multiple bootstrapping, the compilation of apparently complex syntax is made surprisingly easy, and some unexpected advantages were reaped: the syntax can quickly be extended to meet a sudden good idea. The culmination of that is our discovery that a substantial amount of POPLER(Davies, 1971)--a language similar to MICROPLANNER(Sussman, Winograd & Charniak, 1971)--could be implemented in GLUE very quickly without writing any assembly-code at all. The last feature is the interpreter. Although stored program could be kept as machine code, it would occupy a lot of space. Instead it is stored as a sequence of instructions in a format resembling machine code, but for some different sort of machine. When the instructions are to be obeyed, the interpreter takes them one by one and performs the appropriate act. A single instruction can therefore be used to say "take the contents of location suchand-such and put them on the stack". A description of interpreters is given by Knuth (1968, pp. 197 et seq.)
Elementary Facilities As we have said, this is not a programming manual. A fairly adequate description of the elementary facilities is available in Green (1972). Here only
340
T. R. G. GREEN AND D. J. GUEST
a few brief examples will be given. For legibility we shall use both upper and lower case and shall underline certain syntax words. In practice, only upper case is available on our machine, and any inconsistencies of case should be ignored. No indication of underlining is used in practice, e.g. for is typed FOR, just as an ordinary GLUE variable called FOR would be. This is quite important, because as it happens, for really is an ordinary GLUE variable called FOR. Also note that assignment is written the unusual way round; 3-~Xmeans"X becomes equal to 3". The first example is a function to count:
func Count N; vars J; for 1 by l to N ~ J do print(J); print(",");
end;
fin; Count is a function of one parameter, N. Local workspace, J, is created. (Both J and N are local to this function, and these names can be re-used in other functions.) The sequence "for 1 by 1 to N ~ J do" is equivalent to ALGOL "for J: ----- 1 step 1 until N do", or a FORTRANloop " D O 999 J = 1, N, 1"; everything from the do up to the paired end is performed with J ---- 1, 2 , . . . . N . (ALGOL users note that there is no begin, but end is compulsory.) Inside the loop the commands are: print the current value of J; print a comma. The definition closes with fin. The command "Count(3);" would print the numbers 1, 2, 3 each followed by a comma. Next a function to double a number:
func Twice N ; 2*N;
fin: The point of this example is that Twice has a result, 2*N, which is left on the stack. Thus, the command "Print(Twice(3) );" gives 6. Input uses the function "Read", which reads one word and leaves on the stack a number or a string object. Defining a "word" raises a problem, particularly since--as will be described in the section, Bootstrapping--GLUE has to read its own program. A word will certainly end at a space or new line character, but what about the string B O Y + G I R L ? It is convenient to partition characters into the classes letters, digits, signs, and punctuation (which includes brackets), and to set up two rules: a word ends when a new class of character is encountered (except for letters which can be followed by letters or digits); any punctuation character is a complete word in itself.
A LANGUAGE FOR EXPERIMENTS
341
Thus BOY+GIRL is three words. Special facilities are provided to read signed numbers and lists. A recursive function: func Factorial N; / f N < 2 then 1 else N'Factorial(N-I) end;
fin; No-one would normally generate N! like that, of course. The list-processing facilities provided are quite standard LISP-like ones, using the names " H d " and " T I " for LISP "car" and "cdr". "Cons" is used in the same sense. A first-class account of this sort of list-processing is given by Woodward (1966). More sophisticated varieties are available these days and could be implemented: see Hopgood (1969) and Knuth (1968); but it is doubtful whether experimental work needs such sophistication yet. Cognoscenti will be glad to know that garbage-collection is automatic. To set up an array 3 × 4: vars A; array([1, 3, 1, 4])-->A; The square brackets enclose an "explicit list"; the entire expression corresponds to ALGOL'S integer array A[1:3, 1:4]; To address the array A: 1 ~A(2,2); print(A(2,3)); Assignments can also be made into some functions, just as they are made into arrays. One such is the function Hd which finds the head of a list; if X contains the list (A, B) then we can turn it into (C, B) by "C" -~Hd(X); Simple peripherals, such as digital inputs and outputs, are handled by straightforward functions---e.g. ADC(3) gives the current reading of channel 3 of the analogue-to-digital converter. The repertoire of such functions is obviously governed by the devices at hand. More complex peripherals, such as clocks and visual displays, will demand special treatment. At this installation we have an interrupt-generating clock which can also supply a reading of absolute time as a double length integer. We find it convenient to provide a function "Timzero", which sets registers to zero and if necessary starts the clock, and another function "Timset" which places appropriate values into two GLUEvariables, "Secs" and "Huns", giving the elapsed time since the last
342
T. R. G. GREEN AND D. J. GUEST
"Timzero". A further function "Pause(N)" causes GLUE to pause for a specified length of time. The visual display is controlled by an entirely separate program provided by the manufacturers, and GLUEhas the requisite facilities for communicating with that program. Although provisions for priority interrupt handling have been designed, we have not so far needed to implement them. It seems that the main use of priority interrupts in our work has been to look after the clock; with that accomplished behind the scenes, it is enough just to scan the various possible inputs occasionally. There is one further syntactic device of importance to be mentioned. At the start of this section, a distinction was drawn between simple data objects, comprising numbers, strings, and lists, and compound data objects, comprising arrays and functions. The distinction has the following effect. Supposing a variable X contains a simple data object, then the simple command X; will put the contents of X on the stack. If, on the other hand, X contains a compound data object, such as a function for writing a word, then the same command will cause the function to be executed. Now assume that we wish to make a second copy of the contents of X. If X is simple, then the command X-~Y; is sufficient; but if X is a function, that command assigns to Y the result of X. To get a copy of X, we need to prevent X being executed, by
nonop X ~ Y ; after which, Y behaves just like X. If X is simple, "'nonop X" has the same force as bare "X". (A copy of X does not mean a second copy of the entire data structure; as will be seen below, X will always hold a pointer to the data, unless it is a number. Thus a copy of a non-number is simply a second pointer to the same data.) The nonop device is used for passing on array parameters and function parameters; a function to print an array would be called by
printarray(nonop AA); The device also allows one to set up an array of functions, which can be executed with the command "Obey". More important, one can also overwrite a variable containing a compound object; we will need this facility later when we come to the Proglist device. Suppose F holds a function object; then the command 3~F:
343
A LANGUAGE FOR EXPERIMENTS
would be meaningless, since F gets executed. But instead we write
3 ~nonop F; and the variable F now has the value 3. Notice that the function has not been destroyed; we have merely lost the pointer to it that was held in F. Many pointers can point to the same function, so losing one need not matter. Further Facilities Although available to the user, the further facilities are primarily used in compiler writing; they allow the passage from GO tO G2. The term "macro-definition" is used in computing science to mean a variety of devices of differing sophistication. GLUEmacros are very unsubtle ones. It will be recalled that when a function name occurs outside a function definition the named function is executed; when the named function occurs inside a definition, a reference to the named function is stored. A macro, by contrast, is always executed when its name is read, whether the name occurs inside or outside a definition. In all other respects it is like an ordinary function, except that it starts with the word macro:
macro Star; print ("*")
.an; If a function definition commences with
func Nonsense; do this and that; Star; . .
,
. .
then when GLUE reads "Star" it will immediately print an asterisk. (When Nonsense is executed, no asterisk will be printed; Star has done its job.) The second facility is the Proglist, a device which allows GLUE to input to itself. Proglist is a system variable which contains a list, originally set to N I L (the empty list), but which may be altered in the usual way. The input function used by the compiler always examines the current state of Proglist. If Proglist contains NIL, the input function reads from the usual input source, paper tape or console, but if there is a proper list in Proglist, the first member of the list is taken as the word which has been "read" and the remainder of Proglist is put back into Proglist. The Proglist device is available for use by macro definitions. For example, one can get tired of writing "for I by 1 to N ~ J do" since it is so frequent.
344
T. R. G. GREEN AND D. J. GUEST
The construction "upto N ~ J do" would make life easier. We supply this by making a macro Upto which puts on to Proglist the words, For, 1, By, 1, To~ The compiler therefore sees a conventional for statement, and no recoding has been necessary. Finally, there are functions which allow direct compilation of interpreter instructions. During compilation, interpreter instructions are put down sequentially, and the next location to be used is referred to as the current placing address. A variable called QCPA stores the current placing address, and so by changing its contents the user can decide where the next instruction will be stored. A function QPLANT can be used to store an instruction in the location referred to by QCPA. Some macros make use of these facilities to plant instructions. An example is given below, where the conditional THEN is described.
Implementation of GO The language of GO is implemented in assembly-code, and forms the foundation for bootstrapping as explained in Overview. Programs in GO are written in a notation called reverse Polish in which the "infixed" operators ( + , *, etc.) follow their two arguments; A B + means A÷B. Functions, likewise, follow their parameters: X FF means FF(X). No brackets are needed in this notation, and it has the useful property that by the time a function, such as + or FF, is to be executed its parameters are ready on the stack. There are two distinct aspects to implementation, the data base and the mechanisms for operating on it. The GLUEdata base involves, in the first place, the various kinds of data object. A data object occupies exactly one word, whatever kind of object it is, so that no messy problems arise in passing the objects about. The object consists of a tag, which indicates its type, occupying the three most-significant bits and a data field occupying the rest of the word. Positive integers have tag 000, negative ones have tag 111, and the rest of the bits contain the usual two's-complement representation, which speeds up arithmetic. Tags for other objects are arbitrarily chosen, and the data field contains an address. For example, a string object has, in its data field, the address of the stored string, as shown in Fig. l(a). [In our implementation we use a technique called hash coding (Hopgood, 1969) which requires us to store the address of the address of the string, but the principle is the same.] A list object is represented by a pointer to a pair of words, one of which is the head of the list and the other its tail, both of which themselves contain data objects [Fig. l(b)]. Arrays and functions are a bit harder: the data object points to an information block which in turn points to the actual array or
345
A LANGUAGE FOR EXPERIMENTS
function, as well as containing useful information. In the case of functions [Fig. 1(c)] it is convenient to arrange matters so that a function is executed by performing a standard machine-code subroutine entry, regardless o f whether the function is in machine-code or interpreter code. The latter sort will activate the interpreter for themselves.
ioo, i
o
I
' c
(o)
"
-
.....
Vector of strings pocked as convenient
ob,ec,
[
(b)
Oec, JTo,, -"-".
,"
Vector of list nodes
Tog Data IOl'~/~ IObject ° (c }
.['" .... c ...... ] - Machinecode
1
~--~
L ..... - ......
-{Or-}if undefined) .,J ~Machine code for
updater (-I if undefined)
Vector of function addresses FIG. l. Objects. (a) The string object CAT. (b) A list object. (c)A function object. The hatched area holds bits indicating whether the function is a macro, is redefinable, is being traced, and possesses an updater.
The other main item in the data base is the dictionary of variables, which is very straightforward. Each known variable occupies two words of the dictionary. The first word contains one field which stores syntactic information to be used by the reverse Polisher described below, and one field pointing to the name of the variable. The second word of the variable is a data object,
346
T. R. G. GREEN AND D. J. GUEST
the value of the variable (Fig. 2.) The dictionary is preset with the correct entries for all variables required in GO. Maintaining the housekeeping of the data base requires a few mechanisms for stack manipulation which are trivial, and two rather less trivial mechanisms. List space is maintained by a technique described in Knuth (1968, pp. 411-20), which embodies automatic garbage-collection. Array garbage is not automatically collected; the user must explicitly erase his array. The technique used here is also described by Knuth (pp. 435-51). For array addressing see Hopgood (1969).
°
C
A
Object
Dictionary of variable~
Vector of strings
FIG. 2. Variables. First word is the name, CAT, second is value. The hatched area holds bits indicating the name's precedence and brackethood.
We now come to the mechanism of the main cycle of GLUE,which, inputs a command, obeys it, and recycles. In GO, neither the reverse Polisher nor the Proglist is operative; input and operations proceed exactly one-for-one, and the input is always from the tape or the console. Each word is input by executing the function RD; that RD is a fully-defined GLUEfunction, available to the user, and every time the compiler uses it the value of RD (and thereby the address of the machine-code subroutine) is obtained by consulting the dictionary. (The passage from GO to G2 involves redefining RD, so that the compiler will then be calling a different function "without knowing".) The mechanism is: (1) Find value of variable RD. (2) Execute RD. This leaves a result on the stack, which is the word just input. (3) Unload from stack into X and examine the tag field of X. If the tag is not a number, go to 4; otherwise, put X back on the stack and go to 1. (4) I f X is not a number it must be a name. Search the dictionary of names for a match and go to 5 when the match is found; if no match, report an error and go to 1.
A LANGUAGE FOR EXPERIMENTS
347
(5) If the value of the matching variable is a simple data object, put it on the stack and go to 1. Otherwise execute the function, or call the array-addressing mechanism, and go to 1. The second main mechanism is the actual compiling algorithm, which is activated by the command FUNC. Leaving out various checks, this can be represented as: (1) Input next word, create a variable with that name and appropriate value. This input, like all others, uses the function RD. (2) Input parameter list, creating more variables, and set P = number o f parameters. (3) Input local workspace declarations, creating more variables, and put L = number of these. (4) Store machine code for "call interpreter", then interpreter code for "preserve return address left by machine subroutine entry", for "preserve P + L variables" and for "unload P objects from the stack into the parameter variables". This step is discussed below. Set • M O D E = 1; this is a flag to show compiling is going on, used by QTHEN, etc. as described below. (5) Input a word into X. I f X is " F I N " go to 8. I f X is a number, store code for "put X on stack" and go to 5. If X is a string, consult dictionary: if X is known go to 7, otherwise to 6. (6) Create a variable called X. (By convention, X is marked as a function which has not yet been defined.) (7) If the value of X is a macro, execute X and go to 5. Otherwise store code for "execute variable whose address is . . . . ", followed by the address, and go to 5. (8) Store code for "restore P ÷ L variables" and "restore return address", as discussed below, and for "stop interpreting". Store machine code subroutine exit instructions using the return address. (9) Destroy, by overwriting with an impossible value, the name words of the parameters and local workspace in the dictionary, so that names may be re-used. Also destroy any labels that have occurred. Set M O D E = 0. Exit to return address. The words "preserve" and "restore", in steps 4 and 8, refer to the operation of putting copies of parameters, etc., on to an auxiliary stack which is not
348
T. R. G. GREEN AND D. J. GUEST
available to the user. This operation is performed at the start of execution of every interpreter-code function, by the instructions of step 4, and at the termination of the function the copies are taken off and put back by the instructions of step 8. By these means every function becomes potentially recursive. Provision for syntax words, like conditionals and GOTOs, is made by definining them as macros. To illustrate, consider the one and only conditional allowed in 6o. This conditional is of the form
QTHEN QEND, where the commands can include nested conditionals. (We use the prefix Q not followed by U for names the user should avoid.) QTHEN: (1) If MODE = 0, report error and exit. (2) Put current placing address on stack. Put "QTHEN" on stack. Store a zero in the current placing address. Exit. QEND:
(1) If MODE = 0, report error and exit. (2) If top item on stack is not "QTHEN" report error and exit. Otherwise remove it. (3) Take next item off stack; this is the address of the zero stored by QTHEN. Store, in that address, interpreter code for "if top of stack is false jump to (current placing address)". Exit.
Using these definitions, each QEND pairs itself with the appropriate QTHEN. Some of the syntax macros need to input the next word of the text being compiled. An example is GOTO, which is followed by a label name. To make life simple, in GO we have label settings written as colon followed by label (:L rather than L:) and so colon is another macro which inputs the next word. The macro " (quotes) inputs two words. Once again, all these inputs are performed through the function RD. Compiling GOTOs introduces one more type of data object, the label, which is not normally accessible to the user. The value consists of a program address with one bit reserved to indicate "not yet set" or "already set". A string of GOTOs occurring before their target labels creates a label variable, marked "not yet set", pointing to the most recent GOTO; at that point in the code is stored the address of the previous GOTO, and so on back, ending with --1 in the first GOTO. When the label is encountered each member of the chain is overwritten with the required jump. The main mechanisms of the basic language GO have now been described.
A LANGUAGE FOR EXPERIMENTS
349
Bootstrapping The passage from GO to G1 is straightforward in outline, although a few details like for-statements get a bit intricate. G1 is similar to GO except that macros have been defined for conditionals and for-statements, and the Proglist mechanism has been implemented. The conditional construction/f .... then . . . . else . . . . end can be used to illustrate the macros. The word i f has no semantic significance. The macro IF leaves " Q I F " on the stack. The words then, else and end are each the name of a macro, defined as follows. T H E N (1) If top of stack is not " Q I F " , error. Otherwise remove it. (2) Leave current placing address (CPA) and " Q T H E N " on stack. Store a zero at the CPA, using QPLANT. ELSE
(1) If top of stack is not " Q T H E N " , error. Otherwise remove it. (2) Take next item off stack; this is the address left by THEN. Store, in that address, interpreter code for "if top of stack is false jump to ( C P A + 1)". At the CPA store a zero. Leave on the stack the address of the zero and "QELSE".
END
(1) Remove top of stack into X. If X is " Q T H E N " , take off next item; store, in that address, interpreter code "if top o f stack is false jump to (CPA)". Exit. (2) If X is "QELSE", take off next item; store, in that address, interpreter code for "jump unconditionally to (CPA)". Exit. (3) (Various other possibilities which need not be considered.)
The effect of the two constructions, with and without else, is shown in Fig. 3. In practice GLUE also allows the syntax words elseifand exit, as used in POP-2 (the former also in ALGOL68). The definition of the Proglist mechanism makes use of the ability, given by nonop, to copy and overwrite functions, as described in the section, Elementary Facilities. If, without altering the assembly code, we are to make the compiler call a new input function which will examine the Proglist, we must do it by overwriting the value word of the variable RD, which is what the compiler calls. On the other hand a copy of that value must be preserved, because that is the only way to get access to it. We therefore start by putting a copy of RD into a new variable Read: vars Read; nonop RD ~ R e a d ;
350
T. R. G. GREEN AND D. J. GUEST
(a)
(b)
TEXT
CODE
TEXT
CODE
if
(no code)
if
(no code)
...
predicate
predicate
then
jump on false--[
jump on false...
commands
"""
I
commands
end
(no code)
I
else
commands
...
<--
...
then
j ump unconditionally--]
commands end
(no code? - - 1 - - ' )
commands
[
...
[
<-
FIo. 3. The compilation of code for two conditionals: (a) i f . . . then.., end; (b) i f . . . then.., else.., end.
Now we write a function that will examine Proglist and will either take the first member of Proglist or else call Read, as appropriate. This involves some list-processing notation--in reverse Polish, of course.
func Qdummy; if Proglist Null then Read else Proglist Hd; Proglist TI ~Proglist; end;
fin; Notice that the result of Qdummy, which is either the value of Read or the value of Proglist Hd ( = first member of Proglist), is left on the stack as a normal GLUE function result. Furthermore, notice that when defining the main cycle in Implementation of GO, we farsightedly specified that RD left its result on the stack. So we now perform the instruction
nonop Qdummy ~nonop R D ; and the trick is complete; the compiler, when calling RD, will execute Qdummy. The Proglist device is used in defining certain macros. In Further Facilities we showed how the macro UPTO redefines itself as a for-statement. It is perhaps of some interest to note that the macro F O R then in turn rewrites itself by putting onto the Proglist a sequence of commands including a GLUE
A LANGUAGE FOR EXPERIMENTS
351
while-statement, which has the form while < p r e d i c a t e > do < c o m m a n d s > end. Need one add that W H I L E is also a macro ? At the risk of labouring the point quite unduly, we would like to draw attention to the comparison between the ease of defining one's syntax as multiply-bootstrapped macros and the unpleasantness of writing assemblycode nightmares to compile the same code. The Proglist device is also used by the reverse Polisher. At this point we at last reach G2, the user language. The problem for the reverse Polisher is to input text in infix notation and output it in reverse Polish. Input is via the function Read, which we have just created above, and which reads from tape or console, not from Proglist. Output from the reverse Polisher is on to Proglist, so that the compiler gets fed with reverse Polish notation. More on this below. For the sake of the reverse Polisher, a precedence number is associated with every known name (see Fig. 2). Unknown names, numbers, and most known names have a precedence of zero; infixed operators, such as + and *, have precedences greater than zero; q- and --have precedence 3 and * has precedence 2. (Only the rank order is important.) Semicolon has precedence 16. It is also necessary to associate a "bracket-hood" with each name, to be able to handle certain types of construction such as ( A + B ) * ( C + D ) . This is done by marking the name as a left bracket, a right bracket, or not a bracket. The parenthesis brackets "(" and ")" are respectively marked as left and right brackets. The function of bracket-hood is to allow us to distinguish "left precedence" and "right precedence", or precedence over words preceding and words following. "(" has left precedence --1 and right precedence 14, and vice-versa for ")". For + , *, and semicolon, which are not brackets, left and right precedences are equal. It is now possible to state the algorithm. It needs two stacks, one for precedences and one for words; we call these the P-stack and the W-stack. (In practice they are interleaved on the ordinary stack.) We use LP(X) for left precedence of the name " X " , and RP(X) similarly. (1) Put 31, or some large number, on P-stack. (2) Input next word to X. (3) If L P ( X ) > top of P-stack, go to 4. Otherwise, put RP(X) on to P-stack and X on to W-stack and go to 2. (4) Output top of W-stack and put top of P-stack into Y. If L P ( X ) > Y go to 4.
352
T. R. G. GREEN AND D. J. GUEST
(5) If LP(X) = 16 then terminate. Otherwise, put RP(X) on to P-stack and X on to W-stack and go to 2. The reader is invited to check that the following inputs give the stated outputs. The names A, B, C, D, F all have precedence of zero, in both directions. Input
Output
A*B+C; A+B*C; A--B--C; A * (B + C); (A+B)*(C +D); F(A);
AB*C+ ABC*+ AB--C-ABe+()* AB+()CD+()* A()F
For the purposes of GLUE, the algorithm is programmed in GI as a macro with the name "Infix". Besides the job described, Infix performs a few ancillary tasks such as removing comment strings, checking that occurrences of "(" and ")" match, and coping with the complications introduced by quoted atoms. The minus sign makes a slight complication: this context-sensitive symbol can denote either subtraction or negation. We introduce a symbol NEG for the latter meaning, with precedence 1, and on reading " - - " the reverse Polisher replaces it by " N E G " under any of the following conditions. (a) It is the first symbol read by Infix, as in " . . . ; -- 1 ~ X ; . . . " ; (b) it follows a left bracket, as in (--X--Y), where the first minus becomes NEG; (c) it follows a non-bracket which has precedence greater than 4, as in " / f X > -- 1 t h e n . . . " ; (d) it follows a "mixed" bracket; this is the last type of brackethood, with left and right precedences like those of right brackets but like a left bracket as regards minus signs. It is useful to make then and else, etc., mixed brackets: " i f . . . then -- 1 else --2 e n d ~ X ; " The precedence and brackethood values are stored in the 5-bit shaded area of Fig. 2, using 2 bits for the possible values of bracket (none, left, right, mixed) and 3 for the precedence. Facilities are provided to set and to obtain the values associated with a given symbol. Infi~ uses the conventions that symbols which are not in the dictionary of names have zero for both values; symbols which are not brackets have left and right precedences set to the value of the 3-bit field; for a left bracket the left precedence is set to -- 1 and
353
A LANGUAGE FOR EXPERIMENTS
the right precedence to the stored 3-bit value plus 10, and vice-versa for r i g h t and mixed brackets. The output from Infix is kept as a list, and when Infix terminates at s t e p 5 it first joins the list on to the front of Proglist. There is one last trick t o be turned. With Infix's output at the front of Proglist, the compiler will i n p u t , via Rd, text which is in reverse Polish notation until the Proglist is exhausted. At this point it is necessary to reactivate Infix. We do this by arranging t h a t the last word of Infix's output is "INFIX", so that it gets re-activated w h e n that word is read. All that is necessary to start the process. It is the user's responsibility, at present, to start his programs with the word Infix.
An Example In this section we describe a simple sentence-recognizer which, gi ven a grammar and an input string, will decide whether the sentence is g r a m matical. The version described is very elementary, limited in fact to a p r o p e r subset of context-free phrase-structure grammars. (A more powerful v e r s i o n , based on the same algorithm but capable of handling some context-sensitive rules and able to translate from one language to another, has been p r o grammed for experiments on artificial languages (Sime, Green & G u e s t , 1973); but it would be too lengthy to describe here). The algorithm b e l o n g s to the class of "top-to-bottom" parsers. Detailed remarks on this class a n d the alternative class of "bottom-to-top" parsers can be found in F o s t e r (1970). The example shows a short length of program doing a moderately interesting job which would be tiresome to program in assembly-code. We hope t h e example will support our claim that high-level languages are a good p u b l i c a tion medium. The grammar for the sentence-recognizer must be expressed in a s t a n d a r d form; we choose to use rewriting rules. A simple rule would be S::=aSb/ab This rule can be used to generate sentences. Upper case symbols are n o n terminal and will not appear in output sentences; they will be rewritten. Lower case symbols are terminal. The rule gives two alternative ways t o rewrite S, either of which may be chosen; the option sign / separates t h e alternatives. Thus the rule will generate either "ab" or "aSb". The l a t t e r contains a non-terminal symbol which must be rewritten, giving " a a b b " o r "aaSbb"; and so on.
354
T. R . G . G R E E N A N D D . J. G U E S T
The rule can also be used to parse strings. The requirement is that a string should match one of the options, if it is to be a grammatical sentence: " a b " matches the second option; "aabb" matches because the inner "ab" matches the second option, giving "aSb", which matches the first option; "acb" fails. For programming purposes, it is convenient to number the rules. Nonterminal symbols on the right-hand side can then be replaced by numbers. It is also convenient to express the right-hand side as a list of sublists, using one sublist for each option. The previous rule becomes: 1: ( ( a 1 b) ( a b ) ) A rather larger grammar, in the style used by Sime et al. (1973): PROG : : = STATEMENT. STATEMENT : : = if PREDICATE then STATEMENT else STATEMENT / ACT PREDICATE : : = juicy / green / leafy / tall / hard ACT : : -----fry / chop / grill / roast / boil For programming purposes, this would be re-expressed as: 1: ( (2 .)) 2: ( (if 3 then 2 else 2) (4)) 3: ((juicy) (green) (leafy) (tall) (hard)) 4: ((fry) (chop) (grill) (roast) (boil)) The rules of the grammar are stored in an array Grammar, with rule 1 in Grammar(I) and so on. To parse a string is always, by definition, to discover whether it matches any of the options given in rule 1. The algorithm is considered in three stages: first, the problem of matching a single option of a rule; then the problems of considering all options of a rule; last, the text of a function Parse. To establish a match between a string S and one option R of a rule, the following steps are performed: MATCH S WITH OPTION R (1) If every member of R has been successfully matched then R has been successfully matched. (2) If R has at least one member yet to be matched, but every member of S has already been employed in a match, then R cannot be matched.
A LANGUAGE FOR EXPERIMENTS
355
(3) Take the next member (going left-to-right) of R, which is called Hd(R), and put it in A. Put the rest of R, called TI(R), into R. I r A is a number, then it is a non-terminal symbol; in this event go to step (5). (4) (A is terminal). Take the next member of S, Hd(S), and compare to A. If they are different R cannot match S. If they are the same put the rest of S, TI(S), into S and go to step (1). (5) (A is non-terminal). A is a reference to another rule. The simplest way to discover whether it matches is to utilize a function which matches a string against a rule--i.e, the function Parse which we are defining; so call Parse(S,A), recursively. If the match succeeds it may well employ several members of S, so the call returns two results: true or false, indicating the success of the match; and all the members of S which were not matched (equal to all of S if the match failed). Put the latter into S. If the match succeeded go to step (1); if the match failed, R cannot match S. So much for considering a single option. Now we consider all the options of a given rule. In the process above, notice that S, the input string, was continually being used up; so a copy is kept and is used to reset S before each option is tried. Assume that the number of the given rule is in Ruleno at the start, and the string is in String. MATCH STRING WITH RULENO (1) Put Grammar (Ruleno) into Rule. (2) If every member (scil. option) of Rule has been unsuccessfully tried, then it cannot match String; so go to step (5). Otherwise, put the next member of Rule, Hd(Rule), into R; and put the rest of Rule, Tl(Rule), into Rule. (3) Put String into S. (4) MATCH S WITH OPTION R, as above. If successful, exit with the two results true and S and go to step (2). (5) (No match). Exit with the two results fabe and String. The algorithm described makes extensive use of conditionals, requires lists or similar data-storage devices which can hold words and numbers, and is recursive. Perhaps the reader would care to pause briefly to consider programming it in his favourite assembly-code ? or even FOR~AN.
356
T. R. G. GREEN AND D. J. GUEST
Here is a GLUE version. For clarity it has been programmed less compactly than could be achieved. On any call it always returns a truth-value indicating grammaticality, and that part of the input string which was not used up. (In practical situations it is usually necessary to check that all the input string has been used up, after calling Parse.) func Parse(String, Ruleno); vats S, R, A, Rule, T;
Grammar(Ruleno) -~Rule; • while islist(Rule) do
Hd(Rule) -~R; T1 (Rule) ~Rule; String-~S; comment now start inner loop "Match S with R" ; Stepl :
while islist(R) do
Step2:
i f null(S) then goto Nomatch end;
Step3:
nd(R) ~ A ; T1 (R)-~R; ifisnumber(A) then goto Step5 end;
Step4:
i f A = H d ( S ) then TI(S)-~S; goto Stepl; else goto Nomatch end;
Step5:
Parse(S, A) -~T; -~S; i f T then goto Stepl else goto Nomatch end; end; comment if control gets to here, R matches String; S; true; goto Out; comment S is everything that has not been
matched; Nomatch: end; comment end of "islist(Rule)" loop--all options have now failed; String; false; Out:
fin Some Conclusions
These are the two principal features of GLUE. First, no attempt is made to distinguish types at compile-time, so that any variable may turn up at runtime holding any kind of object; and second, an interactive main loop forms the basis for macro-generation of syntax words. Though taken from POP-2 which is intended for a different purpose, these features have given us a
A LANGUAGE FOR EXPERIMENTS
357
language which is easily implemented, has enough power for moderately clever programs, can sense the external world readily enough, and is reasonably legible. What justification can be offered for all those claims ? On the implementation side, work started with a design phase whose fruits are all revealed in this paper. The coding phase, which is the part that would have to be redone for other machines, started in September 1971 and took the two authors three months. No serious revisions have been necessary in nearly two years of active use, although considerable extensions have been made from time to time. However easy to implement, the applications must ultimately justify the language. It is notable that at the time of writing all on-l~n4~projects are being written in GLuE--by the users' choice, not by directive---and the number of fun programs is increasing. Fun programs provide a useful index of a system like ours, because making the computer play board games or setting up a twoperson "display-tennis" program is very similar to our real jobs. If people do it for fun it must be easy enough to do; the big upsurge came when we provided improved display software. (Do not, by the way, discourage users when they leap enthusiastically on your new facilities to amuse themselves --you will get stringent testing done for free.) On the serious side, the study on programming languages by Sime et al. has already been mentioned in the previous section, where we described a simple parser for mini-programs. That study also needed fairly advanced handling of peripherals: the miniprograms were prepared and edited at the display, using a dictionary and a joystick controlling a cursor, and were used to control the actions of a cardsorter and a battery of event lights called "Grill", "Fry", etc. Other uses included several studies on planning and problem solving, where the machine is serving as a memory and sometimes as a test-bed for dummy runs. The data-bases in these studies tend to be rather large and a good deal more complex than could conveniently be handled without list structures, or at any rate some device more powerful than the simple array, because the amount to be remembered varies, pointers to ancestors and descendants must be maintained, and during a dummy run the subject must be able to backtrack away from a trial solution which turned out to be unsatisfactory. Similar considerations apply to a simulation of interviewing behaviour currently under way. At the less cognitive end of psychology, we have performed two experiments on reaction-times to complex visual stimuli, where the computer replaces the st'opwatch. The snags are predictable. GLUEinterprets totally unoptimized programs, so that the execution speed is no better than moderate, and naturally with all
358
T. R. G. GREEN AND D. J. GUEST
that bootstrapping programs compile rather slowly. Moreover it will be appreciated that an interactive program needs to have the whole compiling works available, which uses up core. In our Situation these are not problems. The typical experiment may run for an hour and a half, so that a couple of minutes lost in compiling is not important; execution speed need only keep up with the subject, not usually very difficult to manage; and we have enough core for the job. When we looked in detail at the core allocations we had made, we were surprised to discover how much of our attention had gone on local needs. The M O D U L A R I was designed as a multi-programming system, both as regards the executive and the architecture, and we have attempted to preserve as much power as possible. In consequence a lot of code deals with executive calls, initialization, communication with other programs, and the exigencies of input/output. Yet more, particularly in the system library, deals with display software and with pretty but rarely-used functions. Furthermore we have included hash-coding of strings, to get fast access, which uses a lot of space but which in practic e is not very important (in compiling, the time goes on bootstrapping; in running experiments we keep the subject's hands off the teleprinter anyway). All this complicates the matter of estimating minimal core requirements, but here is a list of core allocations for the smaller of our two standard versions, followed in parentheses by a vague guess at the minimal useable size. Assembly code instructions: 5K (4K). Workspace: hash table, 1K (zero); strings, 1200 (½K); names, 1K (½K); stacks, 600 (¼K); function pointers, 500 (~K); lists and arrays, 2½K (2K); system library, 2½K (1½K); user program, 3K (2K). Total for the minimal size, 11K. What about the limitations of GLUE.9 The lack of interrupt programming has not been a problem--in fact, our experiences with a system which allows interrupts left us with a strong distaste. Not having floating-point is getting to be a problem, not because it's needed for experiments but because GLUE would be so much better than FORTRANII for doing sums in, and the manufacturers haven't supplied us with anything else. We are thinking seriously about providing it. The extreme simplicity of the list structures has not caused any difficulty as yet--really sophisticated structures would hardly be useful, one imagines, in the current state of experimental cognitive psychology. On the other hand, we have definitely had trouble from the necessarily loose syntax of GLUE. Since the compiler cannot tell how many arguments a function takes, nor even whether a given identifier refers to a function, until run-time, one can easily leave objects on the stack by mistake or take off more than one intended. To help debug, really good error diagnostics should be provided. Ours are not good enough, largely because we paid the problem
A LANGUAGEFOR EXPERIMENTS
359
too little attention at the design stage, and progress since then has involved a lot of nasty patching. On the credit side, we did have the forethought to allow errors to be trapped: the error reporting goes through a standard useraccessible function, which can be replaced just as the Read function can be, and this is occasionally very helpful. The final limitation is the machinedependence, and in our eyes this is a virtue in disguise. To obtain machine independence in a real-time system which lives by its ability to talk to its peripherals would need all sorts of dodges; our advice is to forget the idea and program for your own needs. We wish to thank Max Sime for encouragement and calm, Richard Gibson for the diagrams, and all our colleagues who helped in design, discussion, and live trials; also Dr Brian Gaines for helpful editorial advice. References AI'TER, M. J. & WESTBY,G. (1973). The Computer in Psychology. London: Wiley. BOBROW, D. G. & MURPHY, D. L. (1967). Structure of a LISP system using twolevel storage. Comm. ACM, 10, 155. BtrRSTALL, R. M., COLLINS, J. S. & POPPLESTONE, R. J. (1968). POP-2 papers. Edinburgh University Press. DAVIES, D. J. M. (1971). eoezzR: a Poe-2 planner. Memorandum MIP-R-89, Department of Machine Intelligence and Perception, University of Edinburgh. FOSTER, J. M. (1970). Automatic Syntactic Analysis. Macdonald Computer Monographs 7. London: Macdonald/Elsevier. GREEN, T. R. G. (1972). Basic glue Facilities. Technical Memorandum, M.R.C. Social and Applied Psychology Unit, University of Sheffield. HOPGOOD, F. R. A. (1969). Compiling Techniques. Macdonald Computer Monographs 8. London: Macdonald/Elsevier. KNUTH, D. E. (1968). The Art of Computer Programming. Vol. I: Fundamental Algorithms. London: Addison-Wesley. MILLENSON, J. R. (1971). A programming language for on-line control of psychological experiments. Behavioral Science, 16, 248. SIME, M. E., GREEN,T. R. G. & GUEST,D. J. (1973). Psychological evaluation of two conditional constructions used in compt~ter languages, lnternatL J. of ManMachine Studies, 5, 105. SUSSMAN, G., WINOGRAO,T. & CHARNIAK,E. (1971). Micro-planner reference manual. AI Memo 203, Artificial Intelligence Laboratory, M.I.T. WOODWARD, P. M. (1966). List Programming. In Advances in Programming and Non-numerical Computation. Ed. L. Fox. Oxford: Pergamon.