Chapter 21. Modules

Modular design and data abstraction are important in the development of large scale software systems. LogiQL provides a module system to help users structure large programs into manageable pieces.

The LogiQL module system provides developers with several specific benefits. The most significant is that logic written using the module system is recompiled incrementally, according to dependencies automatically extracted from the module definitions. Therefore, if you edit one file, separate compilation will only recompile that file and those that depend on it.

Another benefit to developers is that predicates defined in other modules can be aliased to shorter names, which helps to make logic more concise and easier to read.

Finally, the module system provides for hiding and sealing predicates defined in a file. Only explicitly exported predicates are visible from other files. A predicate may also be defined as sealed, which prevents other modules from modifying it. This ensures that someone else will not intentionally or accidentally add new logic for your predicate, thus invalidating your assumed invariants.

21.1. ConcreteBlox

The basic unit of code in the module system is the concrete block. A concrete block is a set of clauses (declarations, facts and rules), along with a set of export and alias declarations.

A concrete block can be declared as inactive, so that it can be scheduled for execution only when needed.

Here is a short explanation of these terms:

alias

Alias declarations are used by a concrete block to give alternative, presumably shorter, names to predicates, blocks, or namespaces when writing the clauses that comprise the concrete block.

exported predicate

An exported predicate is a predicate declared by a concrete block that may be used by other concrete blocks. Some of these exported predicates may be declared to be sealed.

inactive block

An inactive block is not part of the active installed program. This means that the rules contained therein are not automatically evaluated during maintenance. An inactive block must be scheduled for execution explicitly.

sealed predicate

The contents of a sealed predicate may be observed by other concrete blocks, but they may not insert into that predicate or add new rules that derive into that predicate.

21.1.1. Writing your very first concrete block project

The first step in using the module system to organize a program is to create a new directory to hold your project. We will call this directory test. Once we have a directory to hold the project, we will create inside that directory a project file for the compiler to read (see Section 23.1, “Project Structure”). We will call our project file myproject.project. The file should contain the following text:

myproject, projectname
mylib, module

Like in other project files used in separate compilation, the format is a filename followed by a command and then an optional qualifier. In this case, the project file we have written says that the directory mylib contains a module-based project. Now that we have created the project file, inside the test directory we will create the directory mylib.

Inside directory mylib create a file called A.logic for our first concrete block. Concrete blocks must be written in files with a .logic extension for the compiler to recognize them. Inside A.logic we will write the following:

block(`A) {
  export(`{ p(x) -> . }),
  clauses(`{
    q(x) -> .
  })
} <-- .

This declares a concrete block named A that defines two entities, p and q. Additionally, the concrete block contains a declaration, export(`{ p(x) ->. }) stating that the entity p is exported for use by other concrete blocks. In other concrete blocks A:p will refer to the entity p defined in concrete block A.

Note that the syntax of the concrete block suggests that we are using hierarchical syntax (see Chapter 20, Hierarchical Syntax). However, we currently do not support writing concrete blocks in a desugared form. This may change in future releases.

It is also important to note that the name of a concrete block must match the name of the file in which it is defined, minus the extension. That is, a concrete block called name must be inside a file called name.logic. This restriction allows the compiler to easily determine which files depend upon a changed file: these files must be recompiled.

Now we will create a second concrete block in a new file called B.logic within the mylib directory:

block(`B) {
  export(`{ r[x]=y ->  A:p(x), int(y). }),
  alias(`mylib:A:p, `otherp),
  clauses(`{
    p(x) -> otherp(x).
  })
} <-- .

This declares a concrete block named B that defines a subtype entity p that is not exported and a functional predicate r that is exported.

In this example we introduce the aliasing functionality provided by ConcreteBlox. The declaration alias(`mylib:A:p, `otherp) states that, inside this concrete block, whenever we use the predicate name otherp, we are in fact referring to the predicate mylib:A:p.

Next, inside the mylib directory, we'll create a directory named util. This directory creates a new namespace called util. If you are familiar with Java, this is similar to how it maps directories to package names. However, unlike in Java, concrete blocks need not specify which namespace they live in. To refer to a concrete block within a specific namespace, the name of the concrete block must be prefixed with the namespace name followed by a colon. The directory that we specified in the project file, here mylib and project.txt respectively, is treated as the root namespace.

Inside the util directory we will create another logic file called C.logic:

block(`C) {
  alias_all(`mylib:A),
  clauses(`{
    f[x] = y -> p(x), int(y).
  })
} <-- .

Because the concrete block C is contained within the namespace util, its fully qualified name is util:C. It does not export any predicates.

In the definition of C, we have used the other form of aliasing offered by ConcreteBlox. The concrete block gives the alias declaration alias_all(`mylib:A), which allows all predicates within the concrete block mylib:A to be used without any prefix. That is, the predicate mylib:A:p may be referenced simply by writing p within the concrete block C.

Finally, inside the util directory, we will also create a file D.logic containing:

block(`D) {
  inactive(),
  clauses(`{
    +mylib:A:p(x).
  })
} <-- .

This defines a concrete block D, that like C does not export any predicates. However, unlike C, this block has been declared inactive by writing the declaration inactive(). Scheduling the execution of this this block will cause a new instance of the entity p, defined in the concrete block mylib:A, to be created. You may also specify that a block is active by writing the declaration active() instead, but blocks default to being active if you provide no declaration.

For the compilation and installing of projects into a workspace, please refer to Chapter 23, LogiQL Project.

21.1.2. Names

One of the most significant differences between writing logic in a concrete block and in a legacy block is that colons in predicate names now have semantic meaning. A predicate name like foo that does not contain colons is called a simple name. A predicate name like bar:baz that does contain a colon is called a qualified name.

We may sometimes refer to part of a qualified name up to some colon as a prefix of the name. For example, a prefix of the qualified name bar:baz is bar and the qualified name a:b:c has the prefixes a:b and a.

21.1.3. Name trees

The process of finding a specific predicate from a given predicate name is performed using a structure that we call a name tree. Qualified names in a module project can be seen to form a tree where the edges of the tree are simple names.

For example, suppose we created a module project in the directory project containing the file foo.logic and the directories foo and bar, where foo contains the files one.logic, two.logic and bar contains the file three.logic. The directory structure can be envisaged as follows:

Given the above project, the name tree corresponding to the root of the project would look something like this:

The circles represent namespaces and boxes represent exported predicates. There are a number of differences between two trees to note.

First, the module directory project name is used as the single edge from the root of the name tree.

Second, notice that all concrete blocks have become namespaces. For name resolution purposes, concrete blocks can be seen as defining their own namespaces.

Third, there is only one edge labeled foo from the the project node of the name tree. This is because the project directory contains both a directory and a concrete block named foo. Each edge from a given parent node to its child must have a distinct name, so we cannot have two edges labeled foo. Therefore, the name tree merges the two into a single node. This can only ever happen when there is a directory and a concrete block with the same name and the same parent. Because the project is structured around the filesystem, it is never possible to have two namespaces with the same name or two concrete blocks with the same name as children of the same node.

This name tree is what we call the project name tree, because it is the name computed from the root of the project. Each namespace has its own corresponding name tree.

The fully qualified name of a concrete block or predicate is defined by joining together all the simple names found in path from that concrete block or predicate in the root of the project name tree. So in the example project name tree above, the fully qualified name of the concrete block two is project:foo:two and the fully qualified name of the predicate p contained in the concrete block three is project:bar:three:p.

Determining the node a name points to in any given name tree is very simple. Split the name into a list of simple names by removing the colons. Then starting at the root of the name tree, follow the edges given by the simple names. In the example name tree above, to find the node corresponding to the name project:bar:three:p we would start at the root of the tree, follow the project edge, then the bar edge, then the three edge, and finally the p edge.

Starting from the project name tree, we can recursively construct the name trees for each of its descendent nodes. Given a name tree, to construct the name tree that corresponds to one of its immediate children, we just add edges from the root to each of that child's children using the same labels.

To make it easier to visualize and describe how this transformation takes place, we will label the nodes in the following examples with numbers. These numbers are merely for illustrative purposes and do not correspond to anything written by the user or used internally by the compiler. We will start with an extremely simple example and progress to more complicated ones.

Here, the project name tree just contains a single namespace child foo. To obtain the name tree used by the node named foo, labeled with 1, we simply add an edge labeled with p from foo's only child, the node labeled with 2, to the root:

Technically, at this point, we no longer have a tree, but a directed acyclic graph. In the new name tree we have two possible ways to name the predicate p: as foo:p and as p. Therefore, when writing logic in the concrete block foo we may refer to the same predicate either way depending on aesthetics or readability.

Now let us consider a slightly more complicated example:

Now suppose we want to find the name tree for the node named foo:bar, labeled with a 2. We start by constructing the name tree for the node named foo, labeled with 1. This involves adding edges between the root and all of the children of this node. So we add an edge labeled bar from the root to the node labeled 2 and an edge labeled p from the root to the node labeled 4.

Next, we just repeat the process, but for the children of the bar namespace, the node labeled with 2. This means simply adding an edge labeled with q from the root to the node labeled with 3.

Again, note how there are many ways to refer to the same namespace or predicate. For example, foo:bar:q, bar:q, and q can all be used to refer to the same predicate.

However, the process is not always quite so simple. In some cases it is possible for a node in the name tree to become inaccessible. When this happens we say that the namespace or predicate that is no longer accessible is shadowed. As an example, suppose we started with the following project name tree:

Now let us construct the name tree for the node named foo:foo, that is the node labeled with 2. We start by constructing the name tree for the node named foo, labeled with 1.

Because a node's child edges must all be distinct, when we add an edge labeled foo from the root node to the node labeled with 2, we shadow the original edge labeled with foo that connects the root to the node labeled with 1. We have indicated which parts of the name tree are no longer accessible by coloring them grey. In practice, they are no longer even part of the name tree, but we include them here for illustrative purposes. Next, we repeat the process to obtain the name tree relative to foo:foo.

Again, because we need to add an edge labeled with p from the root node to the node labeled with 3, the original edge labeled p from the root node to the node labeled with 4 becomes inaccessible, and that predicate becomes shadowed.

21.1.4. Aliasing

Alias declarations are interpreted as instructions to add new edges to the root of a name tree. Unlike what we have seen above, aliases are not allowed to cause shadowing. Doing so will result in an ALIAS_PREDICATE or ALIAS_NAMESPACE error.

Given the following project name tree:

The name tree for foo looks like the following:

Now suppose the concrete block foo had the alias declaration alias(`bar:q, `q). This would result in the following name tree:

Note that if foo had the alias declaration alias(`bar:q, `p) it would result in an ALIAS_PREDICATE error.

It is also possible to alias namespaces. For example, if foo also contained the alias declaration alias(`bar, `baz), the resulting name tree would look like:

It is even possible for a concrete block to alias its own name. However, this will only have an effect on naming predicates that the concrete block exports.

It is not possible to alias the result of another aliasing operation. This restricion ensures that the order in which aliases are written does not affect the outcome.

ConcreteBlox also provides the declaration alias_all. This declaration takes all of the children of the given namespace and adds edges to the root with the same names. So for example, given the following project name tree:

The name tree for foo would be:

If foo had the alias declaration alias_all(`bar), the resulting name tree would be:

21.1.5. Name resolution

One of the first steps in compiling a concrete block is that of resolving the names of all predicates to fully-qualified names. This is done by walking over all the predicate names in the logic defined by the concrete block and rewriting them according to the following algorithm:

  1. If the name can be found in the concrete block's name tree, we replace that predicate name with its fully qualified name.

  2. If not, we check whether it is a primitive or built-in predicate.

  3. If not, we check whether it is a predicate defined in a legacy block that was compiled prior to this concrete block.

  4. If not, if the predicate has a simple name, we assume that it is a predicate defined within this concrete block, but not exported. If the predicate's name is bar and the fully-qualified name of its concrete block is foo then its fully qualified name becomes foo:bar.

  5. If not, we report a BLOCK_UNKNOWN_PREDICATE error. If there is predicate with a similar enough name in the name tree, we may report a BLOCK_UNKNOWN_PREDICATE_TYPO error.

21.1.6. Block stage and lifetime

By default, logic defined in concrete blocks is active logic. However, you may override this behavior using the inactive() or execute() directives. For instance, the following defines an execute block:

block(`B) {
  execute(),
  clauses(`{
    +A:foo("a").
}) } <-- .