© Thomas Mailund 2018

Thomas Mailund, Domain-Specific Languages in R, https://doi.org/10.1007/978-1-4842-3588-1_2

2. Matrix Expressions

Thomas Mailund

(1)Aarhus N, Staden København, Denmark

In the next chapter we discuss computer languages in a more theoretical way, but here we will consider a concrete case—the matrix expressions mentioned in Chapter 1. This example is a relatively simple domain-specific language, but parsing matrix expressions, optimizing them, and then evaluating them are all the phases we usually have to implement in any DSL, and the implementation will also have examples of most of the techniques we will cover in more detail later. The example will use some tricks that I will not explain until later in the book, so some aspects might not be evident at this point, but the broader strokes should be and will ideally serve as a sneak peek of what follows in future chapters.

Our goal for writing a language for matrix expressions is to improve upon the default performance of the built-in matrix expressions. We achieve this by taking a more global view of expressions than R does—R will handle each operator one at a time from left to right, but we will analyze expressions and rearrange them to improve performance. These are the steps we must take to do this:

  1. Parse expressions into data that we can manipulate.

  2. Rearrange the expressions into more efficient expressions.

  3. Provide a way to evaluate the expressions.

In this chapter, we use the following library:

library(microbenchmark)

Parsing Expressions

To keep things simple, we will only consider matrix multiplication and matrix addition. We do not include scalar multiplication or inverting or transposing matrices or any other functionality. Adding more components of the expression language in the example will follow the same ideas as we need for multiplication and addition. It will not teach us anything new regarding embedding DSLs in R. When you understand the example, you will be able to do this easily yourself.

With these restrictions, we can say that a matrix expression is either just a matrix, the product of two matrix expressions, or the sum of two matrix expressions. We can represent this as a class hierarchy with one (abstract) superclass representing expressions and three (concrete) subclasses for actual data, products, and sums. If you are not familiar with object-oriented programming in R, we will have a short guide to everything you need to know in Chapter 4. Constructors for creating objects of the three concrete classes can look like these:

m <- function(data) {
  structure(list(data = data),
            nrow = nrow(data),
            ncol = ncol(data),
            def_expr = deparse(substitute(data)),
            class = c("matrix_data", "matrix_expr"))
}
matrix_mult <- function(A, B) {
  structure(list(left = A, right = B),
            nrow = nrow(A),
            ncol = ncol(B),
            class = c("matrix_mult", "matrix_expr"))
}
matrix_sum <- function(A, B) {
  structure(list(left = A, right = B),
            nrow = nrow(A),
            ncol = ncol(B),
            class = c("matrix_sum", "matrix_expr"))
}

We just wrap the parameters of the constructors in a list and set the appropriate class attributes, and we store the number of rows and number of columns because we will need them when optimizing matrix multiplication, as we saw in Chapter 1.

The only purpose of the def_expr attribute we set in the m function is pretty printing. It makes the output of the expressions we manipulate in the following pages easier to follow. Strictly speaking, we do not need any pretty printing for manipulating expressions, but it does make debugging easier, so I tend always to write some code for that. For the matrix expressions, we can use the following code:

toString.matrix_data <- function(x, ...) {
  paste0("[", attr(x, "def_expr"), "]")
}
toString.matrix_mult <- function(x, ...) {
  paste0("(", toString(x$left), " * ", toString(x$right), ")")
}
toString.matrix_sum <- function(x, ...) {
  paste0("(", toString(x$left), " + ", toString(x$right), ")")
}
print.matrix_expr <- function(x, ...) {
  cat(toString(x), " ")
}

Using the constructors and the pretty-printing code, we can try to construct a small expression.

A <- matrix(1, nrow = 10, ncol = 20)
B <- matrix(1, nrow = 20, ncol = 10)
C <- matrix(1, nrow = 10, ncol = 10)


matrix_sum(matrix_mult(m(A), m(B)), m(C))

## (([A] * [B]) + [C])

There is nothing in what we have done so far that qualifies as providing a language as such. We have just implemented a few constructor functions. However, if we overload the multiplication and addition operators for matrix expressions, we get something that starts to resemble a language at least.

`*.matrix_expr` <- function(A, B) {
  stopifnot(ncol(A) == nrow(B))
  matrix_mult(A, B)
}
`+.matrix_expr` <- function(A, B) {
  stopifnot(dim(A) == dim(B))
  matrix_sum(A, B)
}

With these, we can write the same expression more familiarly.

m(A) * m(B) + m(C)

## (([A] * [B]) + [C])

I have put some assertions—the calls to stopifnot()—into the code for the operators to make sure that the dimensions of the matrices involved in operators are valid. We could also have placed these in the constructor functions, but later, we will manipulate expressions where we know that the dimensions are valid, so we do not need to check them there. We do not expect a user to call the constructors directly but use the operators, so this is the natural place to put the checks.

We use the dim function for the sanity check in the addition operator, so we need a version of this that works on matrix expressions. It could look like this:

dim.matrix_expr <- function(x) {
  c(attr(x, "nrow"), attr(x, "ncol"))
}

You might be wondering why we need the m function. After all, it does not contribute anything to expressions instead of just wrapping matrices. Could we just use the matrices directly? The answer is no, and it has to do with how we use operator overloading. For * and + to be the matrix expression versions, we need the first arguments given to them to be a matrix expression. If we wrote simply this:

A * B + C

## Error in A * B: non-conformable arrays

we would be invoking the operators for R’s matrix class instead. And since * is not matrix multiplication (for that you need to use %*% because the * operator is component-wise multiplication), you get an error.

We need a way of bootstrapping us from R’s matrices to the matrices in our expression language. That is what we use m for.

Meta-Programming Parsing

Using an explicit function such as m to bootstrap us into the matrix expression language is the simplest way to use R’s own parser for our benefits, but it is not the only way. In R, we can manipulate expressions as if they were data, a feature known as meta-programming and something we return to in Chapter 5. For now, it suffices to know that an expression can be explored recursively. We can use the predicate is.name to check whether the expression refers to a variable, and we can use the predicate is.call to check whether it is a function call—and all operators are function calls. So, given an expression that does not use the m function and thus does not enter our DSL, we can transform it into one that goes like this:

build_matrix_expr <- function(expr) {
  if (is.name(expr)) {
      return(substitute(m(name), list(name = expr)))
  }


  if (is.call(expr)) {
      if (expr[[1]] == as.name("("))
        return(build_matrix_expr(expr[[2]]))
      if (expr[[1]] == as.name("*") ||
          expr[[1]] == as.name("%*%")) {
          return(call('*',
                      build_matrix_expr(expr[[2]]),
                      build_matrix_expr(expr[[3]])))
      }
      if (expr[[1]] == as.name("+")) {
          return(call('+',
                      build_matrix_expr(expr[[2]]),
                      build_matrix_expr(expr[[3]])))
      }
  }
  stop(paste("Parse error for", expr))
}

In this implementation, we consider both * and %*% matrix multiplication so that we would consider an R expression that uses matrix multiplication as such. Notice also that we consider calls that are parentheses. Parentheses are also function calls in R, and if we want to allow our language to use parentheses, we have to deal with them—like here, where we just continue the recursion. We did not have to worry about that when we explicitly wrote expressions using m and operator overloading because there R already took care of giving parentheses the right semantics.

For this function to work, it needs a so-called quoted expression. If we write a raw expression in R, then R will try to evaluate it before we can manipulate it. We will get an error before we even get to rewrite the expression.

build_matrix_expr(A * B)

## Error in A * B: non-conformable arrays

To avoid this, we need to quote the expression.

build_matrix_expr(quote(A * B))

## m(A) * m(B)

We can avoid having to explicitly quote expressions every time we call the function by wrapping it in another function that does this for us. If we call the function substitute on a function parameter, we get the expression it contains so that we can write a function like this:

parse_matrix_expr <- function(expr) {
  expr <- substitute(expr)
  build_matrix_expr(expr)
}

Now, we do not need to quote expressions to do the rewriting.

parse_matrix_expr(A * B)

## m(A) * m(B)

This isn’t a perfect solution, and there are some pitfalls, among which is that you cannot use this function from other functions directly. The substitute function can be difficult to work with. The further problem is that we are creating a new expression, but it’s an R expression and not the data structure we want in our matrix expression language. You can think of the R expression as a literate piece of code; it is not yet evaluated to become the result we want. For that, we need the eval function, and we need to evaluate the expression in the right context. Working with expressions, especially evaluating expressions in different environments, is among the more advanced aspects of R programming, so if it looks complicated right now, do not despair. We cover it in detail in Chapter 7. For now, we will just use this function:

parse_matrix_expr <- function(expr) {
  expr <- substitute(expr)
  modified_expr <- build_matrix_expr(expr)
  eval(modified_expr, parent.frame())
}

It gets the (quoted) expression, builds the corresponding matrix expression, and then evaluates that expression in the “parent frame,” which is the environment where we call the function. With this function, we can get a data structure in our matrix language from an otherwise ordinary R expression.

parse_matrix_expr(A * B)

## ([A] * [B])

The approach we take here involves translating one R expression into another to use our m function to move us from R to matrix expressions. This involves parsing the expression twice, once when we transform it and again when we ask R to evaluate the result. The approach is also less expressive than using the m function directly. We can call m with any expression that generates a matrix, but in the expression transformation, we only allow identifiers.

As an alternative, we can build the matrix expression directly using our constructor functions. We will use matrix_mult and matrix_sum when we have a call that is *, %*%, or +, and otherwise, we will call m. This way, any expression we do not recognize as multiplication or addition will be interpreted as a value we should consider a matrix. This approach, however, adds one complication. When we call function m, we need to call it with a value, but what we have when traversing the expression is quoted expressions. We need to evaluate such expressions, and we need to do so in the right environment. We will need to pass an environment along with the traversal for this to work.

build_matrix_expr <- function(expr, env) {
  if (is.call(expr)) {
    if (expr[[1]] == as.name("("))
      return(build_matrix_expr(expr[[2]], env))
    if (expr[[1]] == as.name("*") || expr[[1]] == as.name("%*%"))
      return(matrix_mult(build_matrix_expr(expr[[2]], env),
                         build_matrix_expr(expr[[3]], env)))
    if (expr[[1]] == as.name("+"))
      return(matrix_sum(build_matrix_expr(expr[[2]], env),
                        build_matrix_expr(expr[[3]], env)))
  }
  data_matrix <- m(eval(expr, env))
  attr(data_matrix, "def_expr") <- deparse(expr)
  data_matrix
}

Most of this function should be self-explanatory, except for where we explicitly set the def_expr attribute of a data matrix. This is the attribute to be used for pretty printing, and when we call the m function, it is set to the literate expression we called m with. This would be eval(expr, env) for all matrices we create with this function. To avoid that, we explicitly set it to the expression we use in the evaluation.

Once again, we can wrap the function in another that gets us the quoted expression and provide the environment in which we should evaluate expressions.

parse_matrix_expr <- function(expr) {
  expr <- substitute(expr)
  build_matrix_expr(expr, parent.frame())
}


parse_matrix_expr(A * B + matrix(1, nrow = 10, ncol = 10))

## (([A] * [B]) + [matrix(1, nrow = 10, ncol = 10)])

There is more to know about manipulating expressions, especially about how they are evaluated, but we will return to that in later chapters.

Expression Manipulation

Our goal for writing this matrix DSL is to optimize evaluation of these matrix expressions. There are several optimizations we can consider, but R’s matrix implementation is reasonably efficient already. It is hard to beat if we try to replace any computations by our own implementations—at least as long as we implement our alternatives in R. Therefore, it makes sense to focus on the arithmetic rewriting of expressions.

We can rewrite expressions recursively and use a generic function with specializations for the three concrete classes we have. A template (that does not do anything yet) would look like this:

rearrange_matrix_expr <- function(expr) {
  UseMethod("rearrange_matrix_expr")
}
rearrange_matrix_expr.matrix_data <- function(expr) {
  expr
}
rearrange_matrix_expr.matrix_mult <- function(expr) {
  matrix_mult(rearrange_matrix_expr(expr$left),
              rearrange_matrix_expr(expr$right))
}
rearrange_matrix_expr.matrix_sum <- function(expr) {
  matrix_sum(rearrange_matrix_expr(expr$left),
             rearrange_matrix_expr(expr$right))
}

These functions traverse a matrix expression and return the same expression structure. We can modify the functions based on patterns of expressions, however, to start rearranging.

We can make some reasonable guesses at how many operations are needed to evaluate an expression from these two rules: 1) multiplying an n × k matrix to a k × m matrix involves n × k × m operations, and 2) adding two n × m matrices together involves n × m operations. If we can do any rewriting of an expression that reduces the number of operations we have to do, then we are improving the expression.

There are some obvious patterns we could try to match and rewrite. For instance, we should always prefer (A + B)C over AC + BC. However, we can probably expect that the programmer writing an expression already knows this, so we will likely get little to gain from such obvious rewrites. Where we might get some performance improvements is when expressions consist of several matrices multiplied together. There, the order of multiplications matters for the number of operations we have to perform, and the optimal order depends on the dimensions of the matrices; we cannot merely look at the arithmetic expression and see the obvious way of setting parentheses to get the best performance.

Optimizing Multiplication

Before we start rewriting multiplication expressions, though, we should figure out how to find the optimal order of multiplication. Let’s assume that we have this matrix multiplication: A1 × A2 × … × A n . We need to set parentheses somewhere, say (A1 × A2 × …A i ) × (Ai+1…× A n ), to select the last matrix multiplication. If we first multiply together, in some order, the first i and the last n – i matrices, the last multiplication we have to do is the product of those two. If the dimensions of (A1 × …A i ) are n × k and the dimensions of (Ai+1…× A n ) are k × m, then this approach will involve n × k × m operations plus how long it takes to produce the two matrices. Assuming that the best possible way of multiplying the first i matrices involves N1,i operations and assuming the best possible way of multiplying the last n – i matrices together involves Ni+1,n operations, then the best possible solution that involves setting the parentheses where we just did involves N1,i + Ni+1,n +n × k × m operations. Obviously, to get the best performance, we must pick the best i for setting the parentheses at the top level, so we must minimize this expression for i. Recursively, we can then solve for the sequences 1 to i and i + 1 to n to get the best performance.

Put another way, the minimum number of operations we need to multiply matrices A i ,Ai+1,…,A j can be computed recursively as Ni,j = 0 when i = j and
$$ {N}_{i,j}=underset{k}{min}left{{N}_{i,k}+{N}_{k+1,j}+mathrm{nrow}left({A}_i
ight)	imes mathrm{ncol}left({A}_k
ight)	imes mathrm{ncol}left({A}_j
ight)
ight} $$
otherwise. Actually computing this recursively would involve recomputing the same values many times, but using dynamic programming we can compute the Ni,j table efficiently, and from that table we can backtrack and find the optimal way of setting parentheses as well.

In the following implementation, we assume that we have such a list of matrices as input. We then collect their dimensions in a table, dims, for easy access. Then, we simply create a table to represent the Ni,j values and fill it using the previous equation. Once we have filled the table, we backtrack through it to work out the optimal way of multiplying together the matrices from 1 to n, given the dimensions, table, and matrices.

arrange_optimal_matrix_mult <- function(matrices) {
  n <- length(matrices)
  dims <- matrix(0, nrow = n, ncol = 2)
  for (i in seq_along(matrices)) {
    dims[i,] <- dim(matrices[[i]])
  }


  N <- matrix(0, nrow = n, ncol = n)
  for (len in 2:n) {
    for (i in 1:(n - len + 1)) {
      j <- i + len - 1
      k <- i:(j - 1)
      N[i,j] <- min(dims[i,1]*dims[k,2]*dims[j,2] + N[i,k] + N[k + 1,j])
    }
  }


  # Backtrack through the table. This function will
  # be defined shortly.
  backtrack_matrix_mult(1, n, dims, N, matrices)
}

We use a table of matrix dimensions because it allows us to compute the minimum of the expression using a vector expression over k, something we could not do using the A list quite as easily. We loop over the length of intervals rather than just i and j because we need to compute the N[i,j] values in order of increasing lengths for the dynamic programming algorithm to work. If we did not do it in this order, we would not be guaranteed that the N values we use in the expression are filled out yet. Otherwise, though, we just implement the computation sketched earlier.

The backtracking function is equally simple. We want to find the optimal way of multiplying matrices i to j, and we have the table that tells us what Ni,j is. So, we should find a split point where we can get that value from the recursion. That is where we should set the parentheses and then solve to the left and right, recursively, until we get to the base case of a single matrix, which of course is already the result we should return.

backtrack_matrix_mult <- function(i, j, dims, N, matrices) {
  if (i == j) {
    matrices[[i]]
  } else {
    k <- i:(j - 1)
    candidates <- dims[i,1]*dims[k,2]*dims[j,2] + N[i,k] + N[k + 1,j]
    split <- k[which(N[i,j] == candidates)][1]
    left <- backtrack_matrix_mult(i, split, dims, N, matrices)
    right <- backtrack_matrix_mult(split + 1, j, dims, N, matrices)
    matrix_mult(left, right)
  }
}

At each step in the backtracking function, we construct a multiplication object using matrix_mult, so we rearrange the original expression in this way.

Expression Rewriting

With the dynamic programming algorithm in place, we know how to arrange multiplications in optimal order. We need to have them in a list, however, to access them by index in constant time in the backtracking function, but what we have as input is an expression that gives us a tree of mixed multiplications, addition, and data objects. So, the first step we must perform in the rearrangement is to collect the components of the multiplication in a list.

It is simple enough to visit all the relevant values in an expression. We recurse on all matrix_mult objects but not data or matrix_sum objects since it is these that we want to collect. It is inefficient to traverse the tree and grow an actual list object one element at a time; whenever you extend the length of a list object by one element, you need to copy all the old elements. Instead, we can implement a linked list—that we can prepend elements to in constant time—and translate that into a list object later.

To see this in action, we can consider a simpler tree first.

leaf <- function(x) structure(x, class = c("leaf", "tree"))
inner <- function(left, right)
  structure(list(left = left, right = right),
            class = c("inner", "tree"))

Let’s say we have such a tree as shown here (and we do not a priori know that it has four leaves):

tree <- inner(leaf(1), inner(inner(leaf(2), leaf(3)), leaf(4)))

And say we want to construct a list containing the values in the leaves.

One way to implement linked lists is as a list object containing two values, which are the head of the list (an actual value) and the tail of the list (another list, or potentially NULL representing the empty list).

Since head and tail are useful built-in functions in R, we will call these two elements car and cdr instead. These are the names they have in the Lisp programming language and many other functional programming languages. We can construct a list from a car and cdr element like this:

cons <- function(car, cdr) list(car = car, cdr = cdr)

To traverse a tree, we use recursion, but we don’t want to test the class of subtrees explicitly. Here is what we would usually do: have a test for the base case of having a leaf and another case for when we have an inner node. This approach would be sensible on this simple tree. However, once we start working with expressions where we can have many different node types, it might not be obvious what should be considered a base case or a recursive case. For any particular traversal, it is better to use generic functions.

collect_leaves_rec <- function(tree, lst)
  UseMethod("collect_leaves_rec")


collect_leaves_rec.leaf <- function(tree, lst) {
  cons(tree, lst)
}
collect_leaves_rec.inner <- function(tree, lst) {
  collect_leaves_rec(tree$left, collect_leaves_rec(tree$right, lst))
}

Using a generic function like this is certainly overkill for this simple example, but it illustrates the idea, which will be useful for more complex trees. Each node type is responsible for handling itself and potentially recursing further if this is needed. Here, the leaf handler prepends the tree to the list that is passed down the recursion. The tree is just the leaf, so this is the value we want to collect. The result is an updated list that we return from the recursion. For inner nodes, we first call recursively toward the right, passing along the lst object. This will prepend the elements in the right subtree to create a new list that we then pass along to a recursion on the left subtree.

The result of this traversal is a linked list containing all the leaves. To create a list object out of this, we need to run through the list and compute its length, allocate a list of that length, and then run through the linked list again to insert the elements in the list. This is one of the few tasks in R that is easier done with a loop than a functional solution, so that is what we will use.

lst_length <- function(lst) {
  len <- 0
  while (!is.null(lst)) {
    lst <- lst$cdr
    len <- len + 1
  }
  Len
}
lst_to_list <- function(lst) {
  v <- vector(mode = "list", length = lst_length(lst))
  index <- 1
  while (!is.null(lst)) {
    v[[index]] <- lst$car
    lst <- lst$cdr
    index <- index + 1
  }
  v
}

To improve the readability of the example, we will just add a function that gives us a vector instead of a list.

lst_to_vec <- function(lst) unlist(lst_to_list(lst))

Now we can use the combination of the traversal and transformation from the linked list to implement the function we want.

collect_leaves <- function(tree) {
  lst_to_vec(collect_leaves_rec(tree, NULL))
}
collect_leaves(tree)


## [1] 1 2 3 4

We can use the same approach to implement a better version of the rearrange_matrix_expr.matrix_mult function from earlier—one that rearranges the multiplication instead of just returning the original expression. We need it to collect the components of the multiplication—those would be data and sum objects—and then rearrange them using the dynamic programming algorithm.

rearrange_matrix_expr.matrix_mult <- function(expr) {
  matrices <- collect_mult_components(expr)
  arrange_optimal_matrix_mult(matrices)
}

The collect_mult_components function can be implemented using a traversal with a generic function like this:

collect_mult_components_rec <- function(expr, lst)
  UseMethod("collect_mult_components_rec")
collect_mult_components_rec.default <- function(expr, lst)
  cons(rearrange_matrix_expr(expr), lst)


collect_mult_components_rec.matrix_mult <- function(expr, lst)
    collect_mult_components_rec(expr$left,
              collect_mult_components_rec(expr$right, lst))


collect_mult_components <- function(expr)
    lst_to_list(collect_mult_components_rec(expr, NULL))

We use the default implementation to prepend expressions that are not multiplications to the list we are building, while we call recursively on the multiplication objects. Once we have collected all the components we need in a linked list, we translate it into a list object that lets us look up elements by index, as we need in the dynamic programming algorithm.

To see the rearranging in action, we can create the expression we used in the previous chapter. We have four matrices that we multiply together without setting any parentheses.

A <- matrix(1, nrow = 400, ncol = 300)
B <- matrix(1, nrow = 300, ncol = 30)
C <- matrix(1, nrow = 30, ncol = 500)
D <- matrix(1, nrow = 500, ncol = 400)


expr <- m(A) * m(B) * m(C) * m(D)

This implicitly sets parentheses such that the expression will be evaluated by multiplying from left to right.

expr

## ((([A] * [B]) * [C]) * [D])

This, however, is not the optimal order. Instead, it is better to first multiply A with B and C with D and then multiply the results.

rearrange_matrix_expr(expr)

## (([A] * [B]) * ([C] * [D]))

Expression Evaluation

We want to do more than manipulate matrix expressions; we want to evaluate them. This is something we can do easily in a recursive way, using a generic function to handle the different cases once again.

eval_matrix_expr <- function(expr) UseMethod("eval_matrix_expr")
eval_matrix_expr.matrix_data <- function(expr) expr$data
eval_matrix_expr.matrix_mult <- function(expr)
  eval_matrix_expr(expr$left) %*% eval_matrix_expr(expr$right)
eval_matrix_expr.matrix_sum <- function(expr)
  eval_matrix_expr(expr$left) + eval_matrix_expr(expr$right)

The base case, the matrix_data case, gives us an R object that should be a matrix. In the recursive calls, we use matrix multiplication (%*%) and addition (+) on the results of recursive calls, so what we apply to these operators on are R objects—which means that the + operator is not the operator we wrote to create matrix_sum objects.

Since we are explicitly delaying the evaluation of matrix expressions so we can rearrange them for optimal evaluation, we need a way to trigger the actual evaluation, and this would be the natural place to rearrange an expression as well, so we write a function for that.

v <- function(expr) eval_matrix_expr(rearrange_matrix_expr(expr))

Of course, we can also combine the parsing—the meta-programming approach that we looked at earlier—and an evaluation of the expression. We can write a function for doing faster evaluation of an expression like this:

fast <- function(expr) {
  v(build_matrix_expr(substitute(expr), parent.frame()))
}

As long as we stick to %*% and + operators, this function will evaluate to the same value as a plain matrix expression.

all(A %*% B %*% C %*% D == fast(A %*% B %*% C %*% D))

## [1] TRUE

However, it is not generally usable because we have changed the definition of *. You can modify the parser, though, and you have an optimizer for speeding up your matrix multiplications.

res <- microbenchmark(A %*% B %*% C %*% D,
                      fast(A %*% B %*% C %*% D))
options(microbenchmark.unit="relative")
print(res, signif = 3, order = "mean")


## Unit: relative
##                       expr min   lq mean median
##  fast(A %*% B %*% C %*% D) 1.0 1.00 1.00   1.00
##        A %*% B %*% C %*% D 5.8 5.79 5.09   5.85
##    uq  max neval
##  1.00 1.00   100
##  5.41 2.36   100

The recursion in build_matrix_expr stops the first time it does not recognize a call object and creates a data object. A better implementation would try to go deeper and optimize as much of the expression as it could, but this is more an exercise in meta-programming than in domain-specific languages.

As a DSL, matrix algebra is really simple. It’s so simple that you might not consider it a language at all perhaps, but it is; algebraic notation is a DSL that is so useful that we get so familiar with it that we forget how amazing it is compared to the alternative—prose. Still, what we have implemented in this chapter is very simple, and while we might use the meta-programming techniques for code optimization, we probably would not write a DSL for something as simple as this. Nevertheless, the example illustrates the phases in reading, analyzing, and evaluating expressions we see in most DSLs. The three phases can be simpler or more complex in other DSLs (the “analysis” step might be entirely missing), and they might be merged so parsing and evaluation are done as a single step, but conceptually these are the steps we usually see.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.117.182.179