1 Introduction
Declarative programming emphasizes the principle of expressing properties of a given problem in a high-level and execution-independent manner. In functional programming languages, equations specify the meaning of functions applied to given argument patterns. These equations are used to reduce an initial expression to a value. In logic programming languages, the meaning of predicates or relations is specified by Horn formulas (implications). The non-deterministic resolution principle (Robinson Reference Robinson1965) uses these formulas to compute solutions to a given query.
Functional logic languages (Antoy and Hanus Reference Antoy and Hanus2010; Hanus Reference Hanus2013) combine these programming paradigms in a single language environment. In order to abstract from small-step operational details, appropriate evaluation strategies are required. Lazy or demand-driven strategies ensure that iterated reduction steps w.r.t. given equations compute a value if it exists (Huet and Lévy Reference Huet and Lévy1991). Non-deterministic applications of program rules with overlapping left-hand sides ensure that solutions are computed whenever they exist (Lloyd Reference Lloyd1987). The combination of these techniques is called narrowing (Slagle Reference Slagle1974; Reddy Reference Reddy1985). Needed narrowing is a demand-driven variant which is optimal w.r.t. the length of successful derivations and the number of computed solutions (Antoy Reference Antoy1997; Antoy et al. Reference Antoy, Echahed and Hanus2000).
However, the combination of demand-driven and non-deterministic evaluation steps might cause efficiency problems in concrete implementations. To sketch this problem, consider the following operations written in Curry (Hanus Reference Hanus2016):Footnote 1

not is a standard function whereas aBool is a non-deterministic operation (González-Moreno et al. Reference González-Moreno, Hortalá-González, López-Fraguas and Rodríguez-Artalejo1999) which uses Curry’s archetypal choice operation “
$\mathtt {?}$
” that returns one of its arguments. Non-deterministic operations could have more than one value for a given input, for example aBool has values False and True. They are an important concept of contemporary functional logic languages (see (Antoy and Hanus Reference Antoy and Hanus2010; González-Moreno et al. Reference González-Moreno, Hortalá-González, López-Fraguas and Rodríguez-Artalejo1999) for more details). Non-deterministic operations can be used in data structures or as arguments to functions like any other operation, such as in “not aBool”. Since the evaluation of any (sub)expression might lead to a non-deterministic choice, the occurrences of choices must be handled by the run-time system of the language implementation.
Some implementations use backtracking to handle non-determinism, in particular, implementations of functional logic languages that compile into Prolog, like PAKCS (Antoy and Hanus Reference Antoy and Hanus2000; Hanus et al. Reference Hanus, Antoy, Braßel, Engelke, Höppner, Koj, Niederau, Sadre, Steiner and Teegen2025) or TOY (López-Fraguas and Sánchez-Hernández Reference López-Fraguas and Sánchez-Hernández1999). Backtracking implements a choice by selecting one alternative to proceed with the computation. If a computation terminates (with success or failure), the state before the choice is restored, and the next alternative is taken. A well-known disadvantage of backtracking is its operational incompleteness: if the first alternative does not terminate, no result will be computed. This problem can be avoided by keeping all alternatives in one computation structure (e.g., a graph of expressions) and using a fair strategy to explore this structure.
Pull-tabbing is an approach to implement this idea. It was first sketched in (Alqaddoumi et al. Reference Alqaddoumi, Antoy, Fischer and Reck2010) and formally explored in (Antoy Reference Antoy2011). A pull-tab step is a local transformation that moves a choice in a (demanded) argument of an operation outside this operation. For instance,
is a pull-tab step. Pull-tabbing is used in implementations targeting complete search strategies, for example KiCS (Braßel and Huch Reference Braßel and Huch2007), KiCS2 (Braßel et al. Reference Braßel, Hanus, Peemöller and Reck2011), or Sprite (Antoy and Jost Reference Antoy and Jost2016). Iterated pull-tab steps move choices to the root of an expression. If expressions containing choices are shared (e.g., by applying rules with multiple occurrences of parameters in their right-hand sides), pull-tab steps might produce multiple copies of the same choice. This could lead to unsoundness and duplication of computations. The latter is a serious problem of pull-tabbing implementations (Hanus Reference Hanus2012). For instance, consider the additional operations

From a logical point of view, xor applied to identical Boolean values returns False. Thus, xorSelf should always return False. However, pull-tabbing transforms the single choice occurring in the expression xorSelf aBool into three choice occurrences.

The value True (occurring twice) is incorrect. Note that this is caused by the combination of lazy (demand-driven) evaluation (which passes unevaluated expressions as arguments) and non-determinism, which is evaluated on demand. The problem of unsoundness can be fixed by attaching identifiers to choices (Antoy Reference Antoy2011). However, this does not fix the duplication of choices, which can be detrimental to performance due to repeated evaluation.
The Curry compiler KiCS2 (Braßel et al. Reference Braßel, Hanus, Peemöller and Reck2011) transforms Curry programs into Haskell programs and uses pull-tabbing to handle non-determinism and to offer various search strategies. Actually, KiCS2 attaches identifiers to choices and, thus, suffers from the performance problem sketched above. Hanus (Reference Hanus2012) proposed an optimization which eagerly evaluates demanded non-deterministic sub-expressions. This optimization requires a demand analysis which is non-trivial and often imprecise for complex data structures.
Recently, pull-tabbing has been improved by adding a kind of memoization so that the re-evaluation of shared non-deterministic choices is avoided. This scheme, called memoized pull-tabbing (MPT) (Hanus and Teegen Reference Hanus and Teegen2021), has been used in Curry implementations which transform source programs into Julia programs (Hanus and Teegen Reference Hanus and Teegen2021) or Go programs (Böhm et al. Reference Böhm, Hanus and Teegen2021).
Since MPT has been implemented only in imperative target languages, it is still open to debate whether the “MPT scheme can be combined with the purely functional implementation approach of KiCS2” (Hanus and Teegen Reference Hanus and Teegen2021). A solution to this could be useful since KiCS2 supports maintainability by its high-level target language and is also highly efficient for purely functional computations (Braßel et al. Reference Braßel, Hanus, Peemöller and Reck2011; Böhm et al. Reference Böhm, Hanus and Teegen2021).
In this paper, we present a solution to this challenge by transforming functional logic programs into a monadic representation. Although such a transformation is not new (Wadler Reference Wadler1990; Fischer et al. Reference Fischer, Kiselyov and Shan2011), we present an implementation of the monadic interface which supports memoization of non-deterministic branches and also advanced features of contemporary functional logic languages, such as functional patterns (Antoy and Hanus Reference Antoy and Hanus2005) and encapsulation of non-determinism (Braßel et al. Reference Braßel, Hanus and Huch2004).
Our major contributions are:
-
1. A basic monadic model for Curry that uses memoization to avoid repeated computations.
-
2. Refinements of this basic model for efficiency improvements.
-
3. Extensions to cover advanced features of functional logic languages.
While we do not show the implementation of all extensions in detail in this paper, our implementation is the first Curry implementation that integrates all major features of Curry with a promising performance. A compiler that supports the full range of Curry features based on the presented approach is available at https://github.com/Ziharrk/kmcc.
This paper is structured as follows. The next section reviews some necessary details about functional logic programming. Section 3 presents the transformation of functional logic programs into purely functional programs parameterized by a monad to handle computational effects. Some relevant implementations of pull-tabbing are sketched in Section 4, followed by the implementation of our memoization monad in Section 5. This monad is extended in Section 6 to cover various features of contemporary functional logic languages, like free (logic) variables and unification, encapsulated search, and fair search strategies. In Section 7 we present optimization techniques to achieve a better performance for deterministic computations. We evaluate our approach in Section 8 before we discuss related work in Section 9 and conclude in Section 10.
2 Functional logic programming
We assume familiarity with basic ideas of logic and functional programming and the language Haskell, which we use for our implementation. In the following, we review only concepts of functional logic languages which are addressed by the implementation presented later. Concrete examples are shown in the multi-paradigm declarative language Curry.Footnote 2 More details can be found in surveys on functional logic programming (Antoy and Hanus Reference Antoy and Hanus2010; Hanus Reference Hanus2013) and in the language report (Hanus Reference Hanus2016).
Functional logic languages amalgamate distinguishing features from functional programming (demand-driven evaluation, strong typing with parametric polymorphism, higher-order functions) and logic programming (non-determinism, computing with partial information, constraints). The language Curry has a Haskell-like syntaxFootnote 3 (Peyton Jones Reference Peyton Jones2003) but allows free (logic) variables in conditions and right-hand sides of defining rules. In contrast to Haskell and similarly to logic programming, rule selection is non-deterministic, that is if more than one rule is applicable, all applicable rules are tried. The operational semantics is based on an optimal evaluation strategy (Antoy Reference Antoy1997; Antoy et al. Reference Antoy, Echahed and Hanus2000) – a conservative extension of lazy functional programming and logic programming.
As an example, consider the “classical” functional logic definition of the operation last to compute the last element of a list (“
” is the standard list concatenation operator, “=:=” denotes unification, and free variables are introduced by the keyword free):

This definition uses a conditional rule where the condition is solved by evaluating ys
[x] (which instantiates ys to some list) and unifying the result with the input list xs.
As mentioned in Section 1, non-deterministic operations (González-Moreno et al. Reference González-Moreno, Hortalá-González, López-Fraguas and Rodríguez-Artalejo1999) are an important feature of contemporary functional logic languages. They are conceptually equivalent to free variables (as shown in (Antoy and Hanus Reference Antoy and Hanus2006)) and can be nested like other functions. This is due to the fact that non-deterministic operations return (non-deterministically) individual values rather than sets of values. Their declarative meaning can be specified with a set-valued semantics (González-Moreno et al. Reference González-Moreno, Hortalá-González, López-Fraguas and Rodríguez-Artalejo1999). For instance, consider the following operation that inserts an element at an unspecified position into a list:

Hence, the expression insert 0 [1,2] non-deterministically evaluates to one of the values [0,1,2], [1,0,2], or [1,2,0]. One can use this operation to easily define permutations:

Although perm is defined by non-overlapping rules, the use of insert has the effect that perm [1,2,3,4] non-deterministically evaluates to all 24 permutations of the input list.
Compared to approaches where sets or lists of values are passed between operations, as in the “list of successes” approach in purely functional programming (Wadler Reference Wadler1985), non-deterministic operations lead to simpler program structures. Furthermore, they have operational advantages: since expressions are evaluated on demand, non-deterministic operations as arguments result in a demand-driven construction of the search space, leading to considerably smaller search spaces (see (Antoy and Hanus Reference Antoy and Hanus2010; González-Moreno et al. Reference González-Moreno, Hortalá-González, López-Fraguas and Rodríguez-Artalejo1999; Hanus Reference Hanus2024) for more detailed discussions).
As mentioned in Section 1, the occurrence of non-deterministic operations as arguments might yield incorrect results when such arguments are rewritten to multiple occurrences. For instance, if we evaluate the expression xorSelf aBool as in standard term rewriting (Baader and Nipkow Reference Baader and Nipkow1998), that is by applying program rules from left to right, there is the derivation (among others)

The result True is incorrect, as discussed in Section 1. It is interesting to note that this value cannot be obtained with a strict evaluation strategy where arguments are evaluated prior to the function calls. To avoid dependencies on the rewriting strategy and exclude such incorrect results, González-Moreno et al. (Reference González-Moreno, Hortalá-González, López-Fraguas and Rodríguez-Artalejo1999) proposed the rewriting logic CRWL as a logical (execution- and strategy-independent) foundation for declarative programming with non-strict and non-deterministic operations. CRWL specifies the call-time choice semantics (Hussmann Reference Hussmann1992), where values of the arguments of an operation are determined before the operation is evaluated. This can be enforced in a lazy strategy by sharing actual arguments. For instance, the expression above can be lazily evaluated provided that all occurrences of aBool are shared so that all of them reduce either to False or to True consistently.
Although sharing is also used in implementations of non-strict functional languages, like Haskell, in order to support optimal evaluation (Huet and Lévy Reference Huet and Lévy1991), we cannot directly map functional logic programs into Haskell programs due to the non-deterministic features. Thus, a correct mapping requires modeling non-determinism and sharing of non-deterministic expressions. For this purpose, we will use a monadic representation of programs.
In addition to these base features, functional logic languages have more features which are useful for application programming. Apart from standard features like modules or monadic I/O (Wadler Reference Wadler1997), encapsulation (Braßel et al. Reference Braßel, Hanus and Huch2004) is useful to collect all results of a non-deterministic subcomputation in some data structure, and functional patterns (Antoy and Hanus Reference Antoy and Hanus2005) are useful to non-deterministically select sub-expressions at arbitrary positions. In the following, we first discuss a scheme to implement demand-driven non-determinism in Haskell. Later, we extend our scheme to more advanced features in order to obtain a full-fledged implementation of Curry in Haskell.
3 Monadic transformation
In this section we present a basic implementation of a kernel of Curry in Haskell. When mapping a functional logic program into Haskell, one has to model non-deterministic computations in a functional manner. A well-known method to represent non-deterministic results in a functional language is the “list of successes” technique (Wadler Reference Wadler1985): a non-deterministic operation is mapped into a function which returns a list of values. Instead of lists, one can also use other container structures, for example trees. In order to abstract from the data structure to collect values, we parameterize the target program by a monad. A monad m is a type constructor with two operations

To model failures and non-deterministic choices, the more specific monadic structure MonadPlus is appropriate since it offers two additional operations

mzero represents a failing computation and mplus a choice between two computations. For instance, the non-deterministic operation aBool defined in Section 1 can be mapped intoFootnote 4

A simple instance of MonadPlus is the list monad (Wadler Reference Wadler1985) where return creates a singleton list, mzero an empty list, and mplus concatenates the argument lists. Then the monadic representation of aBool returns the list of its two values:

The monadic bind operator
is used to pass non-deterministic values of arguments. For instance, consider the Curry expression not aBool. To evaluate it, not must be applied to all values of aBool. Thus, the monadic version of not takes the monadic representation of its argument and returns a monadic value:

Since the bind operator of the list monad applies the second argument to all elements of its first argument and concatenates all result values, we can nest these operations:

Due to the monadic abstraction, one can also use other monad instances, for example search trees, or add more effects to the monadic computation. As shown later, this is the key to our implementation of advanced functional logic programming features.
Because of these considerations, we transform normal Curry code to functional code parameterized by a monad where all transformed operations get and return monadic values, which are non-deterministic computations in our case. Such a transformation for call-by-name and call-by-value languages has been presented by Wadler (Reference Wadler1990) and is also called monadic lifting.
As sketched in Section 2, Curry uses a lazy call-by-need evaluation strategy. However, Haskell’s sharing is not sufficient to obtain call-by-need for the monadic representation. Since the bind operator triggers the evaluation of a monadic value, multiple occurrences of the same expression might be independently evaluated. For instance, the sub-expression aBool of the expression xorSelf aBool (see Section 1) has two occurrences in the further evaluation of this expression which are independently evaluated. In order to conform to the call-time choice semantics, the non-deterministic values need to be shared. While we could evaluate aBool before passing it to xor in xorSelf, this would only be correct because we know xor to be strict. In general, such a call-by-value implementation is incorrect for a lazy language like Curry. Thus, we explicitly model sharing using an approach adapted from both Fischer et al. (Reference Fischer, Kiselyov and Shan2011) and Petricek (Reference Petricek2012). For this, we need to introduce an operator
By passing a “to-be-shared” monadic expression to share and extracting the result using
, we obtain a new monadic expression that respects our call-by-need evaluation strategy. Consider the following translation of xorSelf which uses its argument twice.
On the first evaluation of the new variable x’ inside xorC, the evaluation of the original argument x to share is triggered and the computed result is memoized. On any subsequent evaluation of x’, the memoized result of x is used without evaluating x again.
Now we will present the transformation of Curry code to an explicit monadic variant. For the rest of this paper, we will use Curry to denote our monadic effect type and the following type for transformed functions to increase readability.
3.1 Types
On the type level, the monadic transformation replaces type constructors with their effectful counterparts and wraps the result and argument of each function type in Curry. However, any type quantifiers and type constraints (which might be absent) still remain at the beginning of the type signature and we also wrap the outer type of a function. Figure 1 presents the transformation of types, including the renaming of type constructors by the operation “
$\mathit{rename}$
” (e.g., add the suffix “C”). For instance, the type signature
is transformed into
Type transformation
$[\![ \circ ]\!]^t$
.

The transformed type signature differs from the one we gave previously for notC. The key difference is that we wrap the monadic type constructor Curry around the whole type. The latter is required because a function could introduce non-determinism before being applied to an argument. To see this, consider the following artificial function that is non-deterministically defined as either identity or negation on Boolean values.

The transformed type signature has the form
For this function, having the monadic type constructor Curry at the outer level is necessary. Since we want to decide how to transform the type of a function based solely on its type and not on its implementation, we treat all functions as potentially introducing non-determinism. Note that inlining and rewrite optimizations can get rid of some of the overhead introduced here. Such optimizations are possible with the Glasgow Haskell Compiler (GHC) that we target.
Data type transformation
$[\![ \circ ]\!]^d$
.

3.2 Data type declarations
Due to the non-strictness of Curry, components of data types may also contain non-determinism. As discussed in (Fischer et al. Reference Fischer, Kiselyov and Shan2011) in detail, this can be achieved by transforming also the arguments of data constructors into monadic values. Thus, we need to modify data type definitions (except for primitive data types since they have no components). Because we rename the data types during the transformation, we have to update any type constructor in a type to use the name of its effectful counterpart. We take a look at the transformation of data types next. To support data constructors having unevaluated or non-deterministic components, we transform every constructor. As the (partial or full) application of a constructor can never be non-deterministic by itself, we neither have to transform the result type of the constructor nor wrap the function arrow. This allows us to transform only the parameters of constructors, because they are the only potential sources of non-deterministic effects in a data type. Figure 2 shows the rules for the transformation of data types. The following code shows an example for this transformation. To improve readability, we use regular algebraic data type syntax for the list type and its constructors instead of the special list syntax of Curry and Haskell. The Curry data type

is transformed into

Because the transformation of a constructor differs from the transformation of a function, we later have to treat constructors and functions in expressions differently. We rename constructors and type constructors in our implementation so that we can more easily identify a transformed (type) constructor.
3.3 Functions
The type of a transformed function serves as a guide for the transformation of functions and expressions. We can derive three rules for our transformation of expressions from our transformation of types:
-
1. Each function arrow is wrapped in a Curry type. Therefore, function definitions are replaced by constants that use a sequence of lambda expressions to introduce the arguments of the original function. All lambda expressions are wrapped in a return because function arrows are wrapped in a Curry type.
-
2. We have to extract a value from the monad using
before we can pattern match on it. As a consequence, we cannot pattern match directly on the argument of a lambda or use nested pattern matching, as arguments of a lambda and nested values are effectful and have to be extracted using
again. -
3. Before applying a function to an argument, we first have to extract the function from the monad using
. As each function arrow is wrapped separately, we need this extraction for every parameter applied to the original function.
Implementing this kind of transformation in one pass over a concrete Curry program is challenging. Thus, we assume that a source program is simplified first. For the remainder of this subsection, we assume that our program is already desugared into a flat form:Footnote 5 a program is a set of top-level functions in the form of lambda abstractions (nested definitions are removed by lambda lifting (Johnsson Reference Johnsson1985)), pattern matching is represented with non-nested patterns in case expressions, all cases branches are complete (i.e., branches on missing constructors are completed with failed, the predefined always failing operation), and non-determinism (e.g., overlapping rules) is expressed with the choice operator (?). As shown by Antoy (Reference Antoy2001), any functional logic program or constructor-based conditional term rewriting system can be transformed into this flat form. For instance, the operation insert defined in Section 2 is desugared into the following flat form.

Figure 3 presents the rules of our monadic transformation on programs in flat form. We explain the rules for abstractions and applications in more detail. Most of the other rules are straightforward. Since our transformation only works for correctly typed programs, we assume that the input code to our transformation has been checked for type errors.
Expression transformation
$[\![ \circ ]\!]^e$
(where
$f$
,
$y$
,
$y_1,\ldots ,y_n$
are fresh variables).

3.4 Applications
For better readability, the transformation of a function application uses the auxiliary function
which extracts the “real” function from the monad and applies it to its monadic argument. Thus, a transformed function application applies the transformed function to the transformed and shared argument. An application of a function to more than one argument is represented as multiple nested applications. Similar to (Petricek Reference Petricek2012), we use share in the application of a function to an argument, instead of whenever a variable is brought into scope like in (Fischer et al. Reference Fischer, Kiselyov and Shan2011). This also allows us to get rid of the “deep sharing” that Fischer et al. (Reference Fischer, Kiselyov and Shan2011) required. In contrast to (Petricek Reference Petricek2012), we also consider data types, as well as let and case expressions. Since constructor applications are shared via the application rule, the constructor arguments in a pattern of a case expression are already shared. Let expressions, however, introduce new variables that should be shared across different application sites in the body of the let expression. Therefore, we need to explicitly use share in their translation.
3.5 Lambda abstractions
A lambda abstraction is translated by wrapping it in a return as well as the transformed function constructor Func and translating the inner expression. We do not need to apply share to the argument of the lambda, since any argument supplied to a function is already shared by the rule for applications.
3.6 Transformation examples
As an example, consider the operation not defined in Section 1. The flat form of not is

This is transformed into

As a further example with more complex pattern matching and applications, we show the transformation of the perm operation from Section 2 where we reduced some
$\eta$
-redexes for readability.

Even though the code is now significantly more complex, the user will not have to read or write such code since the transformation can be fully automated. Another thing to note is that the applications of share on y and ys are superfluous in this code snippet. The reason for this and how to avoid these superfluous applications will be discussed in Section 5.4.
4 A history of monadic pull-tabbing
This section discusses some existing monadic implementations of pull-tabbing and their performance deficiencies. Our own implementation builds on some of these ideas but fixes the problems they have.
4.1 Tree-based non-determinism with fingerprinting
As already discussed in Section 3, the monadic transformation of functional logic programs supports different implementations of non-deterministic computations by providing different instances of MonadPlus. The list instance computes a list of all non-deterministic values and corresponds to backtracking search used in Prolog.
In order to support other search strategies, one can collect non-deterministic values in tree structures rather than lists. For this, we use a standard binary tree representation as seen below.

Thus, Leaf represents a single value, Empty no value, that is a failure, and Node a non-deterministic choice between trees of values. With this intuition in mind, it is straightforward to define the Monad and MonadPlus instances as follows.

Using the monadic transformation of functional logic programs with this tree monad, each operation computes a tree of non-deterministic values. By applying different tree traversals, one can easily implement different search strategies, like depth- or breadth-first search. In practice, this is used in the Curry implementation KiCS2 (Braßel et al. Reference Braßel, Hanus, Peemöller and Reck2011) which has options to select various search strategies (e.g., depth-/breadth-first, iterative deepening, parallel) (Hanus et al. Reference Hanus, Peemöller and Reck2012).Footnote 6
Using just a tree for the monadic effect is not sufficient to implement a call-time choice semantics. The tree-based monadic non-determinism yields unintended answers (as with standard term rewriting, see Section 2):

Here we can observe the problem discussed in Section 1, namely the duplication of choices, which potentially leads to unsoundness w.r.t. call-time choice and duplicated computations. The former can be avoided by attaching identifiers to choices. When a choice is created by an occurrence of the operation “
$\mathtt {?}$
”, it is decorated with a fresh choice identifier. In a pull-tab step, that is when a non-deterministic demanded argument causes a choice of results, the choice identifier is passed from the argument to the result. In our example, all three Node constructors in the result tree would be decorated with the same choice identifier. If the tree traversal that extracts result values from a search tree always makes consistent choices, that is selects the same (left/right) branch for identically decorated choices, then the computed values are the intended ones (Antoy Reference Antoy2011). Therefore, implementations of functional logic languages which use pull-tabbing to support flexible and operationally complete search strategies (Braßel et al. Reference Braßel, Hanus, Peemöller and Reck2011; Antoy and Jost Reference Antoy and Jost2016) often require choice identifiers in computations. A computation branch, which evaluates an expression with some decision for non-deterministic choices, contains a fingerprint, a partial mapping from choice identifiers to left/right decisions, and computes values for this fingerprint in a call-by-need manner. This is done in KiCS2 where the search tree traversal is parameterized by fingerprints (Braßel et al. Reference Braßel, Hanus, Peemöller and Reck2011).
Unfortunately, this method to implement non-determinism could cause a serious efficiency problem. Since pull-tabbing moves every choice occurring in arguments to the root of an expression, choices occurring in shared subexpressions are multiplied (before they are removed by fingerprinting). For instance, KiCS2 is the most efficient Curry implementation on purely functional programs (Braßel et al. Reference Braßel, Hanus, Peemöller and Reck2011) but it might be much slower than other Curry implementations for particular uses of non-deterministic operations (Hanus Reference Hanus2012).
4.2 Explicit sharing of computations
A solution to pull-tabbing without fingerprinting is proposed by Fischer et al. (Reference Fischer, Kiselyov and Shan2011). Their implementation not only solves the problem of unsoundness but also aims to avoid the duplication of computations. They use the share operation we mentioned in Section 3 to save the result of a potentially non-deterministic computation on a heap local to the current computation branch. While their approach makes a key step in the right direction, it suffers from a flaw that decreases performance in real-world applications. The fact that results are only stored and looked up locally in the current computation branch implies that results cannot be shared across non-deterministic branches. Consider the following code snippet.

The value of prime800 will be computed for the first time when one argument of the choice operator (?) is evaluated. At that point, the value of prime800 is stored on the heap only in that computation branch. Thus, we do not have the computed value available on the heap of the alternative branch. When evaluating the other argument of the choice, the value of prime800 will be required again. Consequently, we need to evaluate prime800 a second time even though it will yield the same result. In conclusion, deterministic values are not shared across non-deterministic branches in this setting, as already noticed by Fischer et al. (Reference Fischer, Kiselyov and Shan2011). It should be noted that the same problem occurs in a backtracking-based implementation of non-determinism: a computation performed in one non-deterministic branch is undone before an alternative non-deterministic branch is selected.
5 A Non-determinism monad with memoization
In this section we introduce the kernel of our implementation by developing an implementation of a basic memoized non-determinism monad that aims to fix the problems discussed in the previous section. It is based on a solution to pull-tabbing without fingerprinting that enables sharing across non-determinism, proposed in (Hanus and Teegen Reference Hanus and Teegen2021), but here the results of non-deterministic computations are memoized in a different way so that it fits into the functional monadic transformation.
An implementation based on the monadic transformation presented in Section 3 has to implement the functional interface on which this transformation is based, that is an appropriate monad instance and an implementation of the operation share. This is quite similar to the approach of Fischer et al. (Reference Fischer, Kiselyov and Shan2011) since they proposed the same interface. While they give a purely functional implementation of this interface for lazy non-determinism, their approach suffers from the mentioned drawbacks. In order to avoid these, our implementation uses a mutable (global) state to implement memoization. Thus, we implement the same interface (monad and share) but hide behind the interface an impure implementation for the sake of efficiency.
In standard implementations of non-strict functional languages, the node of a computation graph representing an operation is updated in-place with the computed result in order to share it. This is not possible in a functional logic language with an implementation based on pull-tabbing, since an operation might have more than one result. To overcome this problem, computation branches are uniquely identified by branch identifiers. Instead of updating a node in-place, potentially non-deterministic operation nodes contain a (partial) map
$\mathit{tr}$
, called task result map, from branch identifiers to results. When a branch
$i$
has to evaluate some node
$n$
containing an operation with map
$n.{\mathit{tr}}$
, it first checks whether
$n.{\mathit{tr}}(i)$
is defined. If not, node
$n$
is evaluated and
$n.{\mathit{tr}}(i)$
is set to the computed result before returning
$n.{\mathit{tr}}(i)$
. This evaluation scheme, called MPT (Hanus and Teegen Reference Hanus and Teegen2021) and formally described in (Böhm et al. Reference Böhm, Hanus and Teegen2021), avoids the aforementioned problem of pull-tabbing. Its efficiency can compete with backtracking-based implementations (Hanus and Teegen Reference Hanus and Teegen2021). Furthermore, it is a good basis for operationally complete implementations of functional logic languages. Existing implementations (Böhm et al. Reference Böhm, Hanus and Teegen2021; Hanus and Teegen Reference Hanus and Teegen2021) use imperative target languages. In the following, we discuss a high-level Haskell-based implementation that exploits the monadic transformation introduced so far.
5.1 Memoization monad
To extend the tree-based monadic non-determinism implementation with memoization for call-by-need evaluation and call-time-choice, we need storages for memoized results and identifiers to uniquely label every branch of a non-deterministic computation. For the latter, we assume an abstract type of identifiers that support a nextID operation to generate new identifiers.

Based on this abstract type, we define a state to store the current branch identifier and any “parent” identifier that occurred higher up in our tree of non-deterministic choices. The parents are necessary because a deterministic result that was memoized for a certain branch identifier is also valid in any child branch. Finally, we need an IORef to be able to generate IDs that are unique across all branches. The IORef itself will not change during computations but its content will. For example, the operation freshID below updates the content of this IORef to generate a new identifier.

Note that we need a NOINLINE pragma on freshID so that optimizations will not interfere with our usage of unsafePerformIO. Instead of using unsafe features directly, we could also use the UniqSupply type that is used inside the GHC itself, but that one uses unsafe features under the hood as well.
Below we define the type of our basic Curry monad. It is just a state monad on top of our tree structure.

The Monad instance can be derived from the underlying StateT implementation using generalized newtype deriving, but we explicitly need to give the MonadPlus instance so that we can manipulate our branch identifiers correctly.

5.2 Memoizing non-determinism
Now we define the central function share that enables lazy evaluation via memoization.
Compared to the type described by Fischer et al. (Reference Fischer, Kiselyov and Shan2011), we have removed a type class constraint. As mentioned before, Fischer et al. (Reference Fischer, Kiselyov and Shan2011) need this additional type class to ensure that expressions nested deep inside a data structure are shared and memoized as well (referred to as “deep sharing”). However, since we use share at every (constructor) application site, we already get this deep sharing for free. The function share does the actual memoization work. When called with an argument computation, it first creates a new task result map to memoize possible results of this computation. Then it returns a new computation which checks and updates this map accordingly. We present the implementation of share step by step.

To store memoized results, we use the following data type Heap, a mapping from IDs to values, with both an insertHeap and lookupHeap operation. The implementation of the heap is not relevant, as it can be any kind of key-value store.

We initialize the heap by using unsafePerformIO together with newIORef emptyHeap in the function share to create a kind of memory cell where we can manipulate our results.Footnote 7 In the next step, the current branch ID (b1) is retrieved from the state so that we can use the correct ID to insert into the task result map. This is everything that we need to do to prepare a shared computation. The actual execution of our computation ma happens in the returned computation which starts with the return in the last line. We continue at that return.

Here, we retrieve the current (possibly different) state and check if we already have a result saved in the task result map that is valid in the current or a parent branch. Checking the map for a memoized result is done using lookupTaskResult which is defined as follows.

The current map is read using unsafePerformIO and, for the current and each parent branch ID, we check if there is a valid result memoized in this map. Combining all these Maybe a results using the monoidal msum, we obtain the first result if it exists, or Nothing otherwise. Note that there can only ever be at most one valid result in the map.
Now we continue with the Nothing case of share where no valid memoized result exists.

Here, we have to execute the computation ma to obtain a result that we can memoize. Immediately afterwards, we get the current branch ID (b3) so that we can check if the computation ma contained non-determinism. If the branch ID changed during its execution (i.e., b2 /= b3), the computation was indeed non-deterministic and, thus, the new result is valid only for the local branch identifier b3. In the case that the branch ID did not change, ma was deterministic and, thus, the result is globally valid for the branch identifier b1 that was active on the outer monadic layer of share. With this information at hand, we construct a new task result map and update the IORef using unsafePerformIO. We have to force the computation of the result of unsafePerformIO using seq to ensure that it actually happens before the result of the computation ma is returned.
If we actually have a result memoized, the Just branch after our lookup simply returns the result.
5.3 Nested sharing of non-deterministic values
The implementation given above is correct for programs where non-deterministic values are not shared twice or more. However, there are some programs where such a multiple sharing happens. To illustrate the problem, consider the following Curry program.

Semantically, we expect notIf (False ? True) to be equivalent to True ? False. However, with the memoization implementation given above, we get the result True ? True. To see why this happens, let us look at the monadic transformation of notIf.

Remember that x has already been shared during the application of notIf to True ? False. Consequently, x is evaluated before nx since x is demanded by notC. When the computation of nx is forced in either of the branches, the value for x is memoized and can just be returned for any subsequent computation without triggering any new non-determinism. Thus, the current implementation of memoization deduces that nx is deterministic and the value of nx is memoized across branches as TrueC. As a result, our implementation fails in situations where a shared value (nx) depends on another shared value (x) that is forced earlier than the first one.
To fix this, we also memoize whether a value is deterministic and inserted globally, or non-deterministic and inserted locally. On reading a memoized value that was non-deterministic, we simply advance the branch identifier. Thus, a repeated sharing of the same non-deterministic value or a value that directly depends on it will be assumed to be non-deterministic. In other words, we now prevent global memoization of a computation if it depends on a non-deterministic computation and not just when the computation itself introduces non-determinism. Therefore, we need to adapt the implementation. The relevant changes are in both case-branches.


5.4 Improving performance of sharing
There is one major performance bottleneck with this implementation. The problem is that share enters its argument into the memoization heap, even when a previous share has done so already. Repeated sharing of a computation is unnecessary and expensive in both time and memory consumption, as each share introduces another indirection with an IORef. Thus, an obvious performance optimization is to prevent repeated sharing of values as much as possible. To achieve this, we use the following idea.
We prevent unnecessary insertions of share during the monadic transformation whenever possible. Any locally introduced variable (bound via let, case or lambda) has already been shared by either the application or the let rule. Thus, sharing these variables again when they are applied to a function is unnecessary. Consider the following example of a function that reverses the order of elements in a list.

Here, both x and xs have already been shared when they were originally applied to the list constructor (:). Thus, we can avoid sharing them explicitly. To introduce this improvement, we replace in the rules given in Figure 3 the definition of
$alias$
by the new definition shown in Figure 4.
Improved
$alias$
rule.

6 Extending the monad
An important advantage of our monadic implementation is that one can implement new features by modifying the monad without changing the translation of source programs. In this section, we will show extensions that are relevant in contemporary functional logic languages. The first extension concerns the addition of free (logic) variables, which represent unknown values or possibly infinite sets of values. Free variables are required to support unification, that is binding free variables to partially known values instead of enumerating all their values. Thus, we will show how unification can be implemented in our monadic approach. The second extension is encapsulated search, that is a method to collect the results of non-deterministic computations in some data structure. Though this is known in logic programming for a long time (findall), the interaction with demand-driven computations causes new challenges which we will discuss.
6.1 Free variables
Free variables are an important feature of logic programming. They denote unknown values which are refined during a computation. It is well known that free variables can be replaced by value generators, that is non-deterministic operations that yield the (possibly infinite) set of values of a given type (Antoy and Hanus Reference Antoy and Hanus2006). For instance, a value generator aBool for Boolean values was defined in Section 1. Similarly, a value generator for lists of Booleans can be defined by

Although the use of value generators for unknown values is sufficient from a declarative point of view, a crucial feature of logic programming is unification to compute with partial information, that is, without completely instantiating free variables. For instance, if x and y are free variables, the unification x =:= y is solved by binding x to y (or vice versa) without instantiating them to a value. In contrast, if x and y are generators for an infinite set of values, their evaluation might lead to an infinite search space. In order to support an efficient implementation of unification, we need to explicitly represent free variables during run time so that we can implement bindings without generating values.
For this purpose, we use the approach taken by Teegen et al. (Reference Teegen, Prott and Bunkenburg2021) and adapt it to our memoization monad. Central to their approach is the idea that, if a computation demands a free variable by using
, the variable gets narrowed, that is it is partially instantiated. This fits to our monadic compilation scheme: whenever a value of some expression is demanded, the bind operator
is used, that is to extract the scrutinee of a case-match or to extract a function to be applied.Footnote
8
In both cases, we need an explicit value instead of a free variable in order to continue the evaluation.
Teegen et al. (Reference Teegen, Prott and Bunkenburg2021) define a type class Narrowable, which enumerates all constructors of the given type and fills their arguments with new free variables.Footnote 9

Implementations for this class have to be generated for every transformed data type. For example, the instance for the list type looks as follows.

Free variables are created using the function free that will be introduced later. We also need to extend our state with a heap to store the bindings of free variables that have already been narrowed. Since not all free variables have the same type, our heap has to be untyped using existential data types (Perry Reference Perry2005) and unsafeCoerce as shown below. Using unsafe features here will not cause issues at run time, as in a type-correct program every logic variable has a unique identifier and a single type.

Now we can define the new type for our memoization monad extended with free variables. Apart from the state, it differs from our previous monad by having the type (FLVal a) instead of a plain a in its result. This new type differentiates between values and variables, with the latter one containing a Narrowable constraint so that we are guaranteed to be able to narrow a variable that we encounter during evaluation:

Thus, the state of computations with free variables is defined asFootnote 10

Now we can show the implementation of free that we mentioned earlier. It uses the new type FLVal and provides a fresh identifier of some type constraint to Narrowable. Here we use the ID type to uniquely identify free variables in addition to their use as branch identifiers.

Before we define the Monad instance for the CurryFree type, we introduce another helper function for the instantiation of free variables. This instantiation starts by generating a transformed value for each constructor of the corresponding type using Narrowable. For each of those values, we spawn a new computation that inserts a binding for the instantiated variable into the heap and updates the branch identifier accordingly. Thus, the varHeap in every branch contains a mapping from variable identifiers to monadic computations that have already been shared. We then combine the computations for each constructor by non-deterministic choices using msum. It is imperative that we first bind the corresponding variable and then write it on the heap so that repeated lookups of the same variable also share the same identifiers generated by calls to free in the Narrowable instance. Otherwise, two computations in the same branch could compute different identifiers for the free variables in the arguments of a narrowed constructor, leading to unsound results. We do, however, write only monadic values on the heap so that we can also put lazy computations on the heap. Thus, we wrap our instantiated value in a CF (return ..).

Now we can implement the Monad instance of CurryFree. The only interesting bit is that we make a case distinction on the result of the first argument so that we can check whether it is a variable or a value. Recall that we have to instantiate free variables when they are pattern matched in the source program, which corresponds to a
in the monadic translation. In case the result is a variable that is yet unbound, we instantiate that variable before we continue with the computation. Otherwise, we can apply the continuation from the second argument.

6.2 Monadic unification
With the extension of our monad to represent free variables, we are able to implement a standard unification procedure by inserting variable bindings directly onto the heap rather than instantiating these variables.
In the following, we show an implementation of a Prolog-like unification algorithm based on the monad described in the previous section. The important issue is the distinction between free variables and values (see type FLVal defined above). If free variables are unified, a binding between these variables is stored onto the heap instead of instantiating them.
We start by defining a type class Unifiable which contains the operation unify.

Based on unify, we define a generic operation unifyC which checks whether the arguments are free variables or values. Before the check, we have to follow any chain of variables with deref since it can happen that a variable is bound to another one. If both arguments are unbound free variables, we bind one variable to the other without instantiating any of them. In the case that only one of the arguments is a variable, we instantiate it and unify recursively. Whenever both arguments are already instantiated, we simply unify their values.


The instances of Unifiable can be schematically generated together with the transformation of data types. Note that unify either returns TrueC, if both arguments are unifiable, or fails. The instances for base types are easy, as shown for the type of Booleans.

Structured types are unified by pairwise unifying the arguments of identical data constructors, as shown for the list data type.

Finally, Curry’s unification operator, as introduced in Section 2, can be implemented as follows.

The explicit instances of Unifiable for each data type might look cumbersome but, actually, it is a feature of our approach. All these instances can be schematically generated by a compiler. More important, it is not reasonable to have instances for all types. Hanus and Teegen (Reference Hanus and Teegen2020) proposed the introduction of a type class Data which supports value generators, strict equality, and unification for data types. Since functional types have no Data instances, the operators supported by the Data class are overloaded so that a different implementation is required for each data type, as in our approach.
6.3 Encapsulated search
Non-deterministic programming with built-in search supports a compact and elegant programming style, as known from logic programming and also used in functional logic programming (Antoy and Hanus Reference Antoy and Hanus2002). In application programs, it is necessary to encapsulate non-deterministic computations so that the outcomes are collected in some data structure, for example to print the results in a deterministic manner or to select a result according to some criterion. Prolog offers various operations for this purpose, like findall and similar predicates. However, a direct transfer of such predicates to a language with a demand-driven evaluation strategy is problematic. For instance, findall always computes all solutions so that it cannot be applied to infinite search spaces. If an encapsulation operator would return a possibly infinite list or tree, lazy evaluation allows to process part of this structure in a finite computation. Therefore, proposals for encapsulated search in functional logic languages try to support demand-driven evaluation of non-deterministic computations, for example (Antoy and Braßel Reference Antoy and Braßel2007; Antoy and Hanus Reference Antoy and Hanus2009; Christiansen et al. Reference Christiansen, Hanus, Reck and Seidel2013). For instance, if allValues e denotes the list of all values of the expression
$e$
, one can test whether
$e$
has no value by null (all Values
$e$
). Thanks to lazy evaluation, one gets a Boolean result even if
$e$
has infinitely many values.
Unfortunately, the combination of encapsulated search and demand-driven evaluation causes new challenges. A potential problem occurs if the encapsulated expression
$e$
shares subexpressions introduced outside the encapsulation operator. For instance, consider the expression
The obvious question is whether the non-determinism of b should be encapsulated by allValues or not. There is no clear answer but there are different views. Strong encapsulation, inspired by Prolog’s findall, requires encapsulating all non-determinism accessible from the encapsulated expression so that this expression yields the single list [True,False].Footnote 11 Unfortunately, the results of strongly encapsulating search operators depend on the evaluation strategy. If we consider the expression
the computed pairs of values depend on the order of evaluating the components of the pair. If they are evaluated from left to right, we obtain the list of values
With a right-to-left evaluation, we obtain [([True], False), ([False], True)] since, due to sharing, the already evaluated values of b are encapsulated. These and more pitfalls of strong encapsulation are discussed (Braßel et al. Reference Braßel, Hanus and Huch2004).
One can avoid these pitfalls by a clear distinction between expressions evaluated inside an encapsulation operator, that is where all values of these expressions are collected, and outside, where different values lead to different results. For this purpose, Antoy and Hanus (Reference Antoy and Hanus2009) proposed set functions which encapsulate the non-determinism caused by a function but do not encapsulate the non-determinism of arguments. It has been shown that set functions are a strategy-independent notion of encapsulating search in demand-driven non-deterministic languages.
Instead of discussing more details about various search operators, we want to show how we can implement strong encapsulation (other search operators can be based on this by distinguishing the subexpressions to be encapsulated). In the following, we will see a further advantage of our monadic approach. Instead of adapting the compilation scheme to support encapsulated search, as done in other Curry compilers (see Section 9), we use the same monadic target programs but put all the extensions inside the implementation of the monad.
A crucial step for strong encapsulation in a lazy language is reducing an arbitrary expression to a fully-evaluated, constructor-based value. To do this for arbitrary data types, we define a type class NormalForm and an additional operation normalFormCurry.Footnote 12

Since the arguments of an arbitrary data type might contain non-deterministic computations, the result of normalForm can be non-deterministic. Conceptually, the operation “pulls” the non-determinism to the outside of a data structure, which is why this is also known as pull-tabbing.
For our list data type, an instance looks as follows.

Using this type class, allValues can be defined by evaluating the normal form of the encapsulated expression with the current state, collecting all results using some tree traversal (here: depth-first), and converting the Haskell-list into the internal Curry-list representation.

The depth-first tree traversal and list conversion are simply defined as:

An advantage of the tree representation for non-determinism is that we can replace the depth-first tree traversal by other ones, for example breadth-first or iterative deepening. An interesting alternative is an implementation of a fair search (Böhm et al. Reference Böhm, Hanus and Teegen2021) as described in the following.
6.4 Fair search
Consider the following non-deterministic definition.

Conceptually, sometimesLoops has exactly one result. However, both a depth-first search and a breadth-first search will loop forever without ever finding the value True due to the non-terminating computation in the left and/or right branch. As an alternative, a fair search strategy should ensure that all results are computed in a finite amount of time, even in the presence of non-terminating computations in non-deterministic branches.
One can implement a fair search strategy in Haskell by traversing non-deterministic branches of the search tree in concurrent threads where leaf values are written into an answer channel. A basic version of this idea can be implemented with the concurrency features of Haskell (Peyton Jones et al. Reference Peyton Jones, Gordon and Finne1996).

This implementation performs the following steps:
-
1. Create a new channel ch for communicating result values.
-
2. Define a recursive function go to traverse the tree where
-
• leaf values are directly written in the channel ch, and
-
• concurrent threads are spawned for left and right subtrees.
-
-
3. Run go concurrently and collect results by using getChanContents.
While this fair search will spawn a huge amount of threads, these are all relatively lightweight and directly managed by GHC’s run-time system.
A disadvantage of this simple approach is that the channel stays open, even when all threads have already completed their work. To determine when all threads have finished their work, we use Haskell’s mutable variables MVar (Peyton Jones et al. Reference Peyton Jones, Gordon and Finne1996) as a semaphore to compensate the lack of a join operation on threads. If a thread tries to read an empty MVar, it suspends until it is eventually filled by other threads. When all threads have completed their work, the outermost thread will write a Nothing value into the channel to signal completion. Since getChanContents returns the results lazily, taking values until we find Nothing is also lazy enough.

One further optimization implemented in our compiler is the use of finalizers to “hook” onto the garbage collector based on the concepts described by Peyton Jones et al. (Reference Peyton Jones, Marlow and Elliott2000). When the garbage collector cleans up the result list of our fair search, we know that no more elements will be taken out of the channel. Thus, we can stop all threads that are still active. For instance, if we demand only a single value from a search tree with more non-deterministic branches, the remaining threads are automatically terminated after getting a value and garbage collecting the list of possible values.
6.5 Further extensions
As mentioned in Section 2, there are still some other extensions that have been proposed for high-level declarative programming. The most relevant are functional patterns (Antoy and Hanus Reference Antoy and Hanus2005) where one can use defined functions in addition to data constructors in patterns. Functional patterns support a compact programming style since a functional pattern abbreviates a (possibly infinite) set of standard patterns. This allows for deep pattern matching which is exploited in (Hanus Reference Hanus2011) to process XML structures.
For instance, the operation last, shown in Section 2 in a classical functional logic programming style, can be defined with a functional pattern by
Although this is basically the specification of the property to be satisfied by last, it is also executable if functional patterns are supported and even yield results if the list contains failing computations before the last element. Conceptually, a functional pattern denotes all regular patterns to which it can be evaluated. In our example, these are all lists with x as a final element. Antoy and Hanus (Reference Antoy and Hanus2005) showed that functional patterns can be implemented by a non-strict unification procedure where pattern variables are not fully evaluated, in contrast to the operator unify described in Section 6.2. Since the necessary changes are limited, we skip a detailed description of them.
The full implementation of our monad and compiler with all extensions described above is available in our GitHub repository https://github.com/Ziharrk/kmcc.
7 Optimizations for deterministic expressions
Not all operations defined in a Curry program make use of non-determinism and other logic features. In fact, only certain parts of a program are written in a non-deterministic style. These parts are usually encapsulated (see Section 6.3) and their results are then further processed by purely functional or IO-based code. To optimize the performance of our compilation model, we can identify these deterministic parts and essentially keep them as-is in the target Haskell code. As an example, consider the following definitions.

The functions map and length are both deterministic. Thus, computing the size of a list of permuted values should only require a non-deterministic computation for the permutation itself, whereas the remaining part of the computation is purely functional.
Deterministic parts of a program can be identified with a simple static analysis using a fixed-point computation. In the following, we will focus on using this information to optimize the compilation of deterministic parts of a program.
7.1 Deterministic sub-computations
In Section 3 we have shown that data types are transformed into a variant where each constructor argument is a monadic computation. However, in order to keep deterministic parts of a program as-is, we need to keep a deterministic copy of each function together with the original data types. When the result of a deterministic function is passed into a non-deterministic operation, we have to convert the plain representation into the monadic representation of the given data type. These conversions can be easily generated for each data type. In our implementation, they are part of a type class FromHs. Since our target language Haskell is typed, we also need a connection between these data types on the type level. For this purpose, we use an injective type family (Eisenberg et al. Reference Eisenberg, Vytiniotis, Jones and Weirich2014) which maps each Curry type to its Haskell representation. The corresponding class and type family definition as well as an example instance for lists are shown below.

With this conversion, the optimized transformation of the function permutation123 from the example above can be implemented as follows.

There is just one catch with this optimization in the general setting: functions as arguments. If a deterministic function is passed into a non-deterministic one, we need to convert the function into its monadic representation. However, this is not possible in general: the converted function has to operate on monadic values but the function we are trying to convert operates on pure values. One could encapsulate the non-deterministic argument values and map the original function over all these values, but this approach is too strict in certain cases. Thus, our optimization needs to ensure that no deterministic function is passed as an argument to a non-deterministic one. This can be a bit challenging to implement since a function could be “hidden” inside a data type. In any of these cases, we simply fall back to the non-deterministic variants of the involved functions and data types.
A further complication comes from Curry’s demand-driven evaluation strategy. A deterministic function might trigger non-determinism when forcing the evaluation of one of its arguments. Thus, each argument of a deterministic function needs to be deterministic so that the optimized version is applicable.
Due to these complications, a purely static approach would often use non-deterministic variants of functions where it is not necessary at run time. Without the ability to detect deterministic values at run time, once we enter the realm of non-determinism, there is no turning back.
7.2 Marking deterministic values
In order to avoid the problems mentioned above, we can mark a value as deterministic at run time. To do this, we extend each data type by a new constructor with a single argument that contains the deterministic variant of the given type. The updated transformation rule for data types is shown in Figure 5. For the List type, the final definition of its non-deterministic variant is as follows.

Revised data type transformation
$[\![ \circ ]\!]^d$
.

In some cases it can be convenient to have a dual function to fromHs which receives a value in its monadic representation and returns a computation that yields the deterministic representation of the given value. Our compiler uses this function for some foreign function implementations but it is not further relevant to this paper.
Since we added another constructor to each data type, our previous transformation for case expressions becomes incomplete. We need to add a case for the new constructor where we exploit the fact that the argument is known to be deterministic. Since it is easier to define, we duplicate each branch of the case expression, where the new branch pattern includes the new constructor. The revised transformation rule for branches of case expressions is given in Figure 6. It omits the right-hand side of the new branch since it is hard to capture the decision of whether and how to use the deterministic variant of a function in a compact way. The decision is based on the used in-scope variables and their determinism status. An example of the new transformation is shown below for the length function, where succ is the increment function.

Note that one of the branches exclusively uses the deterministic function for the recursive call. If we were to assume that length is non-deterministic, the last branch would look as follows.

Revised transformation rule for branches of case expressions
$[\![ \circ ]\!]^b$
.

One might notice that doubling the number of branches can lead to an exponential increase in the size of the generated code in case of deeply nested case expressions. Actually, this turned out to be practically relevant since there are programs where the flat form yields large case expressions (e.g., pattern matching on strings). This can be avoided by transforming programs so that there is at most one case expression directly at the root of the function. This form of programs, which are also called uniform, can be obtained by moving nested case expressions into new functions (Moreno-Navarro et al. Reference Moreno-Navarro, Kuchen, Loogen and Rodríguez-Artalejo1990). The transformation into uniform programs is also used in other Curry compilers (Braßel et al. Reference Braßel, Hanus, Peemöller and Reck2011; Hanus et al. Reference Hanus, Antoy, Braßel, Engelke, Höppner, Koj, Niederau, Sadre, Steiner and Teegen2025). With uniform programs, we do not need to double the number of branches in case expressions but only add a single one for the new constructor where we call the appropriate version of the same function we are already in. This allows us to still “switch” to the deterministic world without an exponential code increase.
One nice additional benefit of the new constructor is that we can use it to defer the conversion of a value in the FromHs class. Instead of converting a value to its monadic representation immediately, we can just wrap it in the new constructor. This way, we can still pass the value or its sub-components to the deterministic variant of a function.
8 Evaluation
The objective of this work is to create a high-level memoization implementation for Curry based on a purely functional interface. This implementation is intended to support sharing of values to avoid repeated pull-tab steps and to allow for sharing of deterministic computations even across non-determinism.
Due to the monadic abstraction, the implementation of our compiler allowed for a clean separation between the run-time system (i.e., the monad and share implementation) and the monadic transformation. The full compiler implementation that we have now shows that the code size is smaller (thus, better maintainable) and more modular than KiCS2 (Braßel et al. Reference Braßel, Hanus, Peemöller and Reck2011) which also compiles into Haskell.
In order to evaluate the overall efficiency of our high-level implementation, we compare it on various benchmark programsFootnote 13 with three other major Curry implementations. PAKCS (Hanus et al. Reference Hanus, Antoy, Braßel, Engelke, Höppner, Koj, Niederau, Sadre, Steiner and Teegen2025) compiles to Prolog (SWI-Prolog 9.0.4) so that its search strategy is based on backtracking. KiCS2 (Braßel et al. Reference Braßel, Hanus, Peemöller and Reck2011) compiles to Haskell (GHC 9.4.5) and implements non-determinism by pure pull-tabbing without memoization. Curry2Go (Böhm et al. Reference Böhm, Hanus and Teegen2021) compiles to Go (1.19.3) and uses an imperative implementation of memoization. All benchmarks were executed on a Linux machine running Debian 12 with an Intel Core i7-7700K (4.2 GHz) processor with eight cores. We measured the average elapsed time (in seconds) of three runs using the time command and the executables generated by the respective compilers.
Figure 7 shows the timings for various programs. The first seven benchmarks test the properties that our implementation was designed for, while the last two benchmarks are purely deterministic programs. The remaining two benchmarks test expensive non-deterministic computations. Times for our approach are both “Monadic MPT” columns, where the latter one integrates the optimization for fully deterministic sub-computations from Section 7. Both are performed with a depth-first search strategy, GHC optimizations (-O1) and its single-threaded RTS. We will focus on the non-optimized version of “Monadic MPT” first and discuss the optimized implementation at the end of this section. The benchmarks are based on the ones used by Böhm et al. (Reference Böhm, Hanus and Teegen2021) to evaluate MPT.
Compared to our initial prototype which was based on a direct integration into GHC using a compiler plugin (Hanus et al. Reference Hanus, Prott and Teegen2022), the unoptimized implementation of our compiler lost a lot of performance. This seems to be the case for benchmarks with functions that are “more strict” in their arguments, suggesting that GHC’s demand analysis was more effective for the plugin implementation. However, our optimized implementation reaches the same performance as the prototype for all but one benchmark.
Timings (in seconds) of various programs evaluated with different compilers, green marks best time.

The first five benchmarks check memoization, that is the avoidance of repeated pull-tabbing. The contrived programs addNum5 and addNum10 test this property by non-deterministically generating a number x between 0 and 2000 (inclusive, see someNum below) and adding x five and ten times to itself, respectively.

With MPT, the choice for x should be made only once and not on each addition of x. Looking at the times in Figure 7, our implementation requires for addNum10 less than double the time of addNum5. This is in contrast to KiCS2 which does not use memoization to implement pull-tabbing. Compared to our initial prototype, this is the only benchmark where our optimized implementation does not perform with the best timings. We attribute this to the fact that the integration of the prototype into GHC allowed for more aggressive optimizations, especially for numeric-heavy computations. It should be noted that PAKCS and Curry2Go, which are faster on this benchmark, use a direct representation of integers which do not support searching on integer variables (similarly to Prolog), whereas our implementation allows for searching and constraint solving on integers via Z3 (de Moura and Bjørner Reference de Moura and Bjørner2008).
The next three benchmarks select n
non-deterministically select an element in a list of length
$n$
and sums up the element and the list without the selected element. They also demonstrate the advantage of MPT compared to the “raw” implementation of pull-tabbing in KiCS2.
The benchmarks yesSharingND and noSharingND use the primes example from Section 4.2 to create an expensive deterministic computation that yields the 800th prime number.

This computation is used in the following tests, where the first one shares the value of prime800 through a let-binding across the non-deterministic choice and the second one does not.Footnote 14

Since noSharingND takes approximately twice as long as yesSharingND, we can conclude that our implementation shares deterministic values across non-determinism.
The final benchmarks are taken from (Hanus and Teegen Reference Hanus and Teegen2021). permSort sorts a list of 13 elements by non-deterministically generating all permutations and keeping the sorted one. sortPrimes generates prime numbers as shown above and sorts a list of four of them using permutation sort. This benchmark mixes determinism and non-determinism. The large execution time of PAKCS is due to the fact that it does not implement sharing across non-determinism so that the prime numbers are recomputed in each permutation. naiveReverse is a simple deterministic example of the quadratic algorithm to reverse a list of 4096 elements, and queens10 computes the number of safe positions to put 10 queens on a 10
$\times$
10 chessboard using Peano numbers.
These benchmarks indicate that, for purely functional programs, our non-optimized implementation is faster than PAKCS and sometimes Curry2Go but slower than KiCS2. For non-deterministic programs, the results depend on the exact benchmark. Since our implementation optimizes for programs such as addNum10 or select150 where avoiding repeated pull-tabbing is relevant, we are predictably faster than KiCS2. PAKCS is sometimes faster since it uses backtracking in Prolog instead of pull-tabbing.
Unfortunately, the non-optimized version is slower than Curry2Go and much slower than KiCS2 in the mixed benchmark sortPrimes. While this may seem surprising at first, the speed of KiCS2 stems from the fact that it also optimizes deterministic operations, even when deterministic functions are applied to potentially non-deterministic arguments. This is actually very beneficial in the implementation of sortPrimes and for the two deterministic benchmarks as well. Without this optimization, KiCS2 is about as slow as our unoptimized approach.
Using the optimizations for deterministic computations presented in Section 7, we can achieve a similar performance to KiCS2 and sometimes even outperform it in the purely functional benchmarks. This is due to the fact that for those functions our compiler (together with GHC) essentially generates the same code as GHC would have generated if we had compiled the benchmark as a Haskell program. However, since our implementation fundamentally differs from the one in KiCS2, it is still possible to construct programs where KiCS2 outperforms our compiler.
9 Related work
There are two different groups of related works which we discuss in the following subsections.
9.1 Other compilers for Curry
While we have already mentioned other Curry compilers throughout the paper, here we summarize similarities and differences between them and our approach.
The compiler most similar to our work is KiCS2 (Braßel et al. Reference Braßel, Hanus, Peemöller and Reck2011). It compiles to Haskell and the evaluation strategy is based on pull-tabbing as well. However, KiCS2 does not support memoization. While its model of Curry programs is not directly monadic, it bears some resemblance. Instead of having an explicit effect data type like Tree in our approach, the structure is basically inlined by augmenting every data type with a constructor for non-deterministic choices and for failure. Instead of using a state monad to pass around information, KiCS2 augments every function with corresponding parameters. This allows better optimization but results in more complex and less maintainable code. For instance, the implementation of set functions in KiCS2 requires a modification in the compilation scheme by adding an additional argument (the encapsulation level) to each function (Christiansen et al. Reference Christiansen, Hanus, Reck and Seidel2013).
Prott et al. (Reference Prott, Teegen and Christiansen2023) uses the same basic idea of a monadic transformation to augment the GHC with different semantics, including one with Curry-style non-determinism. However, that implementation lacks the performance improvements and concentrates on the integration into the Haskell compiler itself.
Other related compilers are Curry2Go (Böhm et al. Reference Böhm, Hanus and Teegen2021) and its Julia-based predecessor (Hanus and Teegen Reference Hanus and Teegen2021). These compilers introduced and refined the memoization approach to pull-tabbing but both used an imperative language as their back end. Thus, the compilation scheme and their modeling of Curry are substantially different.
Sprite (Antoy and Jost Reference Antoy and Jost2016) compiles Curry to LLVM in order to generate efficient target code. It is also based on pull-tabbing but does not implement memoization. We could not include a comparison in our benchmarks since a working implementation is not available. The results published in (Antoy and Jost Reference Antoy and Jost2016) indicate that the performance of Sprite behaves similarly to that of KiCS2.
The PAKCS compiler from Curry to Prolog (Hanus et al. Reference Hanus, Antoy, Braßel, Engelke, Höppner, Koj, Niederau, Sadre, Steiner and Teegen2025) is very different from our approach. Due to the use of Prolog as a target language for compilation, PAKCS inherits the incompleteness of backtracking as a search strategy.
9.2 Monadic intermediate languages
Our transformation basically models the denotational semantics of Curry explicitly. Peyton Jones et al. (Reference Peyton Jones, Shields, Launchbury and Tolmach1998) have applied such an approach to design a common monadic intermediate language for Haskell and ML. Although we do not use our monadic intermediate language to model two languages, the idea remains the same.
Transforming an effectful language into purely functional code using a monadic style has been used in the past to model functional languages in various proof assistant systems. For instance, Abel et al. (Reference Abel, Benke, Bove, Hughes and Norell2005) generate Agda code that models Haskell’s semantics via an explicit monadic effect.
More recently, a compilation scheme for the algebraic effect language Eff has been presented that translates Eff into monadic OCaml (Karachalias et al. Reference Karachalias, Koprivec, Pretnar and Schrijvers2021). However, Eff is an entirely different language than Curry with different challenges.
10 Conclusions and future work
In this work, we developed an implementation of Curry which supports various advanced features of functional logic languages introduced in recent years. In order to achieve a sufficient evaluation performance even when combining lazy evaluation with non-determinism, we adapted the recent method of memoized pull-tabbing from Curry compilers that use imperative target languages to a functional language. This evaluation strategy is modeled in a monadic style and can be combined with an automatic transformation of Curry programs into monadic Haskell programs. We also extended this implementation to efficiently support free variables from Curry and integrated other extensions, like unification, functional patterns, encapsulated search, or fair search. Due to the monadic structure of the target programs, these extensions can be implemented by modifying the monad only without changing the compilation scheme. The results for small but typical benchmarks indicate a good performance compared to other compilers. Thus, we obtained a high-level, maintainable, and efficient implementation of Curry which supports all language features together with an operationally complete, fair search strategy.
For future work, there is a lot to do. Our determinism analysis and optimization would of course be improved by user-given annotations in the source language, that is enforcing that certain type class functions cannot be non-deterministic. Additionally, using a strictness analysis would also allow us to omit certain share usages and omit laziness when it is not necessary.
Competing interests
The authors declare none.

[[∘]]t
[[∘]]d
[[∘]]e
f
y
y1,…,yn
alias
[[∘]]d
[[∘]]b