Home About Me

Beyond Functional Programming: What Lisp Really Lets You Build

For more than half a century, Lisp has been described as one of the most remarkable programming languages ever designed. When it appeared, it introduced ideas that reshaped later languages, and even now many of its capabilities have no exact equivalent in mainstream practice. Languages can be compared by speed, learning curve, community, vendor support; for me the crucial question is whether a language helps express the programmer's design intentions. C trains you to express design through procedures. Java trains you to express design through objects. Lisp is often tied to functional programming, but functional programming is not its essence. Someone can learn lambda, higher-order functions, closures, lazy evaluation and still find that studying Lisp changes how they think about programming. What Lisp offers is a way to express ideas freely. That freedom, more than any single feature, explains why an old language remains alive.

From list processing to S-expressions

The name comes from List Processing. John McCarthy's 1960 paper 'Recursive Functions of Symbolic Expressions and Their Computation by Machine' presented a simple data structure, the S-expression, as a way to represent both code and data, and built a complete language on that foundation. Paul Graham, in 'The Roots of Lisp,' compared McCarthy's contribution to Euclid's contribution to geometry. The claim sounds grand, but it points to something real: Lisp's form is simple and its consequences are deep.

The form that carries meaning

Yet Lisp has remained mysterious in a way Euclidean geometry has not. At first glance, its most obvious feature is the odd S-expression syntax. An S-expression is either an atom or a list of S-expressions separated by spaces and enclosed in parentheses. The word list can mislead: it suggests a linear linked list, but Lisp lists are nested trees. Here are some examples:


foo

()

(a b (c d) e)

(+ (* 2 3) 5)

(defun factorial (N)
    (if (= N 1)
        1
        (* N (factorial (- N 1)))
    )
)

The story goes that McCarthy used S-expressions as a temporary syntax. He intended to add M-expressions and compile them into S-expressions--roughly the way Java source is compiled into JVM bytecode. Lisp users, however, became comfortable with S-expressions and discovered advantages in writing them directly, so the M-expression layer was postponed indefinitely.

Many introductions stress Lisp's functional side. That emphasis can mislead. The real entrance is not functional programming but the S-expression itself; the language's secret is hidden behind that form. Syntax carries semantics, and form carries substance. An S-expression is a form for programs in the way that a seven-character line is a form for classical Chinese poetry, or a short post is a form for social-media information. A different form creates a different kind of thing. S-expressions are what separate Lisp from C, Java, SQL, and other languages at a structural level.

Why ordinary languages feel rigid

A language's ability to express design ideas depends on the semantic reach of its abstraction mechanisms. Different mechanisms produce paradigms: procedural, object-oriented, functional, concurrent. Choosing a language generally means committing to an orientation. Java usually implies object-oriented analysis and design; Erlang usually implies modeling concurrent work with the Actor model. Experienced programmers know the abstraction principle: decouple what from how. The trouble with ordinary languages is that they offer few ways to express the what. Procedures, classes, interfaces, functions--these cover a great deal, but many domain problems do not map cleanly onto them.

Consider a makefile. You can define C functions that perform the same steps as a makefile, but the C code will not declaratively expose targets and dependencies the way a makefile does. Those ideas become implementation details buried inside functions. Object-oriented or functional languages run into the same limitation. The abstraction dimension represented by makefile syntax is orthogonal to procedural, OO, and FP abstractions, so those paradigms cannot directly express makefile semantics. This is the rigidity of ordinary languages: the problem must be forced into the language's basic syntax and semantics.

The root cause is tight coupling between syntax and semantics. In C, printf("hello %s", name) fits function-call syntax and means function call--nothing else. In Java, interface IRunnable { ... } fits interface-definition syntax and means interface definition--nothing else. If that coupling seems natural, Lisp can make it look contingent.

What do you see in (f a (b c))? Do you immediately think of function evaluation or macro expansion? If you see gcd(10, 15) in C and think function call, or class A in Java and think class definition, you are used to reading along the grain of the language. You ask how the machine will execute this expression, or which language feature it corresponds to. Lisp asks you to reverse that habit. The language can follow you. (f a (b c)) can mean anything you define: a function definition, a class definition, a database query, a file dependency, an asynchronous task relationship, a business rule.

Tree-shaped syntax and semantic softness

S-expressions and XML both have tree-shaped syntax, and in semantic expressiveness there is no essential difference between them. For clarity, the examples below use XML, which many readers know better. They convert easily to Lisp.

We can define a greatest-common-divisor function:


    <func name='gcd' return_type='int'>
        <params>
            <a type='int'/>
            <b type='int'/>
        </params>
        <body>
            <if>
               <equals>
                   <a/>
                   <int>0</int>
               </equals>
            </if>
            <then>
                <return><b/></return>
            </then>
            <else>
                <return>
                    <gcd>
                        <modulo><b/><a/></modulo>
                        <a/>
                    </gcd>
                </return>
            </else>
        </body>
    </func>

We can define a class:


    <class name="Computer">
        <field access="private" type="MainBoard" name="main-board" />
        <field access="private" type="CPU" name="cpu" />
        <field access="private" type="Memory" name="memory" />

        <method access="public" return_type="boolean" name="powerOn" />
            <params>...</params>
            <body>...</body>
        </method>

        <method access="public" return_type="boolean" name="powerOff" />
            <params>...</params>
            <body>...</body>
        </method>
    </class>

We can write a relational query:


<sql>
    <select>
        <column name="employees.id" />
        <column name="bonus.amount" />
    </select>
    <from>
        <table name="employees" />
        <table name="bonus" />
    </from>
    <where>
        <equals>
            <column name="employees.id" />
            <column name="bonus.employee_id" />
        </equals>
    </where>
</sql>

And we can express an automated build similar to a makefile or Ant:


    <project name="MyProject" default="dist" basedir=".">
        <property name="src" location="src"/>
        <property name="build" location="build"/>
        <property name="dist"  location="dist"/>

        <target name="init">
            <mkdir dir="${build}"/>
        </target>

        <target name="compile" depends="init" description="compile the source " >
            <javac srcdir="${src}" destdir="${build}"/>
        </target>

        <target name="dist" depends="compile" description="generate the distribution" >
            <mkdir dir="${dist}/lib"/>
            <jar jarfile="${dist}/lib/MyProject-${DSTAMP}.jar" basedir="${build}"/>
        </target>

        <target name="clean" description="clean up" >
            <delete dir="${build}"/>
            <delete dir="${dist}"/>
        </target>
    </project>

These examples are meant to show what tree-shaped syntax can describe. Notice how naturally it constructs function, variable, and conditional semantics; class, field, and method semantics; relational select and where semantics; build target and dependency semantics. In an ordinary language you can define a function or a class, but you cannot add anonymous functions to C, add RAII to Java, or even create your own foreach loop. In Lisp, defining new semantics means creating a language on top of Lisp. Procedural, object-oriented, functional, relational--each becomes a DSL inside Lisp. Lisp itself becomes a language for defining languages: a meta-language.

This flexibility is tied to S-expressions. Lisp does not restrict what semantics you express with them. The same syntax can serve many domains. That is syntax and semantics decoupled. If ordinary rigidity comes from tight coupling, Lisp's softness comes from decoupling. It lets you build DSLs for arbitrary domains without being forced into one paradigm or one domain perspective. In essence, Lisp programming is a paradigm beyond ordinary paradigms: language-oriented programming, or LOP.

A common description of LOP says that the programmer does not first solve the problem in a general-purpose language. Instead, the programmer creates one or more domain-specific languages for the problem, then solves it in those languages. The approach captures requirements in the user's terms and tries to make the implementation language as isomorphic as possible to those descriptions, so the mapping from requirement to implementation is direct.

LOP starts from the problem. You create a DSL that describes the domain model, then use that DSL to solve the problem. The result is highly declarative and abstract. SQL, makefiles, and CSS are familiar instances of the same impulse. Two examples show why the approach matters.

Example: binary trading messages

In a stock-trading system, a trading protocol defines binary message formats. Both exchange and client must encode and decode messages. The message format is an abstract specification, independent of language. It could be implemented in C, C++, Java, or Python. A conventional implementation defines a structure for each message and writes encode/decode functions. Suppose the work for one message is M and there are N message types. The total work is roughly MN. Each new message type requires a new structure and new codec, and the chance of bugs grows with MN.

The message structures are highly similar, and the codecs differ only in details. Yet ordinary languages lack an abstraction that captures the commonality. An OOP base class cannot factor out what really matters. The commonality lies in domain semantics--for example, the rule that one field contains the MD5 of another field. That is not an OOP abstraction.

LOP creates a message-definition DSL, similar in spirit to Google's Protocol Buffer or Android's AIDL. Message definitions are written declaratively, including domain rules such as the MD5 relationship. A compiler for the DSL generates message definitions and codecs in C, Java, or another general-purpose language. Adding a message then means writing one definition file. The work shifts into the compiler and becomes a constant C independent of N. Quality assurance also concentrates on the compiler, and adding message types does not introduce new codec bugs.

Example: CRUD for a library system

A library management system needs administrative screens for books, students, classes, and other entities. In a traditional three-tier design, the backend defines a class and CRUD methods for each entity, and the frontend has a management page for each. The CRUD operations are similar but not identical. Encapsulation, inheritance, and polymorphism are not enough to capture the commonality; much code still lives in subclasses. Student and Book may look alike, but an OOP attempt to abstract them often produces a vague Entity base class that provides little reuse.

Again, the commonality is domain semantics: an entity has attributes; attributes have types, ranges, read/edit permissions; entities have relationships and constraints. LOP faces those semantics directly. Instead of hand-writing CRUD methods and pages per entity, you define a DSL and its compiler. Entity description files declare attributes, types, ranges, supported operations, relationships, and constraints. The compiler generates backend entity classes and frontend management pages. It can target Java, C#, JSP, ASP.NET, or another stack. Work and quality concentrate in the DSL and compiler, independent of the number of entities. The larger the system, the more LOP pays off.

These cases show LOP as domain-oriented, declarative programming whose abstraction dimension matches the domain model. It frees programmers from repetitive implementation detail and keeps attention on the essence of the problem.

Dedicated DSLs or XML/S-expressions?

If a domain needs a DSL, should you invent a dedicated language like SQL, or define the DSL with XML or S-expressions? Each choice has tradeoffs.

XML and S-expressions have two practical advantages. First, dedicated DSLs such as SQL, makefiles, and CSS serve one domain. Real problems often cross domains, and combining different domain languages is difficult because ordinary languages are rigid. The single syntactic structure of XML and S-expressions, together with their code-as-data character, makes cross-domain fusion much easier. Second, parsing is simpler. Analyzing XML or S-expression DSLs is straightforward. Parsing a dedicated DSL such as SQL can use Lex, Yacc, ANTLR, or similar tools, but the complexity is still higher.

The advantage is also the disadvantage. XML and S-expressions have fixed syntax; they cannot be designed as freely as a dedicated DSL. Dedicated DSLs usually read more concisely. In effect, XML and Lisp trade syntactic freedom for semantic flexibility.

How DSLs run: interpreters, code generation, and macros

DSL execution usually takes one of three forms. A dedicated interpreter executes the DSL. Or the DSL is compiled into code in another language and then interpreted or compiled by that language. Or the DSL is self-interpreting. SQL is a familiar example of the first. The two LOP examples above belong to the second. Lisp's specialty is the third.

To understand self-interpretation, start with internal DSLs. An internal DSL is embedded in a host language. Google Test, for example, defines a C++ unit-test DSL based on a fluent interface. Semantically, an internal DSL borrows host syntax to define domain semantics, which is another case of syntax/semantics decoupling. Operationally, it is interpreted by the host language's interpreter, so it avoids the cost of a dedicated interpreter. That does not mean no design work is needed; the designer must understand host features and use them so the DSL rides on the host's execution model.

Lisp's self-interpretation comes from a distinctive tool: the macro. Macros let a DSL written in Lisp be interpreted directly by the Lisp interpreter. The principle resembles an internal DSL, but internal DSLs usually rely on chaining and other host-language tricks, which limits their form and power. Lisp macros are far more flexible.

C also has macros, but they are different. A C macro is simple string substitution. Consider:


#define square(x) (x*x)

square(1+1) should be 4, but substitution produces (1+1*1+1), which evaluates to 3. C macros operate before compilation, without understanding program structure, and they are fragile. A Lisp macro is not string substitution. It is a complete code-generation system that transforms parsed Lisp code from one form to another. In effect, it does the work of a compiler. The difference is that an ordinary compiler translates one language into another--Java source into bytecode, for example--while a Lisp macro takes S-expressions as input and produces S-expressions as output. It transforms one DSL into another.

Suppose a Lisp interpreter can already execute a procedural DSL, and you need an Ant-like build tool. You can build an Ant-like DSL with macros. Macro expansion turns the build DSL into the procedural DSL, which the Lisp interpreter then executes. The resulting build language needs no compilation into another language and no separate interpreter of the kind XML Ant requires.

Macros are not free. They are harder to understand and harder to debug than a dedicated interpreter or compiler. Whether to write an interpreter/compiler or use macros depends on the situation.

Lisp's single S-expression syntax can express different semantics, decoupling syntax from semantics. That gives it unusual semantic construction power and makes it well suited to DSLs and language-oriented programming. Macros add self-interpretation and make transformations between DSLs fluid. Functional programming and macros are powerful tools, but LOP is the way of thinking that gives them direction. The place to begin is not lambda; it is the idea that a programmer can define the language that fits the problem.