First, declare this library dependency in your build.sbt
:
libraryDependencies += "io.chymyst" %% "curryhoward" % "latest.integration"
The curryhoward
functionality becomes available once you add this import declaration:
import io.chymyst.ch._
This imports all the necessary symbols such as implement
, ofType
, allOfType
and so on.
In this tutorial, we will activate the verbose
logging option:
System.setProperty("curryhoward.log", "verbose")
The value of the "curryhoward.log"
JVM property is a comma-separated list of options.
Other supported logging options include macros
, terms
, and prover
.
The curryhoward
library is a compile-time code generator that implements pure functions given their types.
This works best for functions that perform generic data manipulation that is not specific to any given data type.
Consider this code:
case class User(name: String, id: Long)
def makeUser(userName: String, userIdGenerator: String ⇒ Long): User = {
User(userName, userIdGenerator(userName))
}
The function makeUser
does not actually perform any computation specific to types String
or Long
.
So, this code can be generalized to arbitrary types:
case class User[N, I](name: N, id: I)
def makeUser[N, I](userName: N, userIdGenerator: N ⇒ I): User[N, I] = {
User(userName, userIdGenerator(userName))
}
When types are replaced by type parameters, it is clear that
the only way to get a user ID of type I
is to apply userIdGenerator
to userName
.
We see that the type of the function makeUser[N, I]
constrains its algorithm to such an extent that there is only one way to write the code.
The curryhoward
library can generate the code of functions of this sort using the macro implement
:
scala> case class User[N, I](name: N, id: I)
defined class User
scala> def makeUser[N, I](userName: N, userIdGenerator: N ⇒ I): User[N, I] = implement
<console>:17: Returning term: (a ⇒ b ⇒ User(a, b a)) userName userIdGenerator
def makeUser[N, I](userName: N, userIdGenerator: N ⇒ I): User[N, I] = implement
^
makeUser: [N, I](userName: N, userIdGenerator: N => I)User[N,I]
scala> makeUser(123, (n: Int) ⇒ "id:" + (n * 100).toString)
res1: User[Int,String] = User(123,id:12300)
With the verbose
option set, the library will print the lambda-calculus term corresponding to the generated code.
In this example, the term is User(userName, userIdGenerator userName)
.
The chosen notation for lambda-calculus terms supports tuples and named case classes. Below we will see more examples of the generated terms printed using the lambda-calculus notation.
The curryhoward
library, of course, works with curried functions as well:
scala> def const[A, B]: A ⇒ B ⇒ A = implement
<console>:15: Returning term: a ⇒ b ⇒ a
def const[A, B]: A ⇒ B ⇒ A = implement
^
const: [A, B]=> A => (B => A)
scala> val f: String ⇒ Int = const(10)
f: String => Int = <function1>
scala> f("abc")
res2: Int = 10
The returned lambda-calculus term is (a ⇒ b ⇒ a)
.
Here is a more complicated example that automatically implements the fmap
function for the Reader monad:
scala> def fmap[E, A, B]: (A ⇒ B) ⇒ (E ⇒ A) ⇒ (E ⇒ B) = implement
<console>:15: Returning term: a ⇒ b ⇒ c ⇒ a (b c)
def fmap[E, A, B]: (A ⇒ B) ⇒ (E ⇒ A) ⇒ (E ⇒ B) = implement
^
fmap: [E, A, B]=> (A => B) => ((E => A) => (E => B))
scala> val f: Int ⇒ Int = _ + 10
f: Int => Int = <function1>
scala> def eaeb[E]: (E ⇒ Int) ⇒ (E ⇒ Int) = fmap(f)
eaeb: [E]=> (E => Int) => (E => Int)
scala> val ea: Double ⇒ Int = x ⇒ (x + 0.5).toInt
ea: Double => Int = <function1>
scala> eaeb(ea)(1.9)
res3: Int = 12
In this example, the returned lambda-calculus term is (a ⇒ b ⇒ c ⇒ a (b c))
.
One can freely mix the curried and the conventional Scala function syntax.
Here is the applicative map2
function for the Reader monad:
scala> def map2[E, A, B, C](readerA: E ⇒ A, readerB: E ⇒ B, f: A ⇒ B ⇒ C): E ⇒ C = implement
<console>:15: Returning term: (a ⇒ b ⇒ c ⇒ d ⇒ c (a d) (b d)) readerA readerB f
def map2[E, A, B, C](readerA: E ⇒ A, readerB: E ⇒ B, f: A ⇒ B ⇒ C): E ⇒ C = implement
^
map2: [E, A, B, C](readerA: E => A, readerB: E => B, f: A => (B => C))E => C
If the implement
macro is used to generate a class method or val
class member, value members from the class will be used automatically:
scala> import io.chymyst.ch._
import io.chymyst.ch._
scala> final case class User2[A](name: String, id: A) {
| def map[B](f: A ⇒ B): User2[B] = implement
| val count: Int = name.length // whatever
| val generated: (Int, String, A) = implement
| }
<console>:19: Returning term: (a ⇒ b ⇒ c ⇒ User2(b, a c)) f name id
def map[B](f: A ⇒ B): User2[B] = implement
^
<console>:21: Returning term: (a ⇒ b ⇒ c ⇒ Tuple3(c, a, b)) name id count
val generated: (Int, String, A) = implement
^
defined class User2
scala> val user = User2("abc", 123) // User2[Int]
user: User2[Int] = User2(abc,123)
scala> assert(user.generated == ((3, "abc", 123)))
scala> assert(user.map(_ + 1).generated._3 == 124)
In this example, the generated
value will be defined as the tuple (count, name, id)
since these are the only available values of the requested types.
This functionality may give unexpected results if too many values are visible to the automatically derived expression.
In order to reduce the possibilities of errors, the implement
macro will only look at the immediate class enclosing the definition, and will only use val
s that are not lazy, not abstract, and are defined before the method being generated.
The macro implement
is designed to be used when defining new methods, as shown above.
A different use case is to generate an expression from already available values.
In the example shown above, we had to define a new method makeUser
that creates a value of type User
from other available values.
If we only need to create a value of type User
once in our code, we would like to avoid having to define a new method, only to be used once.
This functionality is provided through the macro ofType
.
Instead of the code
def makeUser[N, I](userName: N, userIdGenerator: N ⇒ I): User[N, I] = implement
makeUser(123, (n: Int) ⇒ "id:" + (n * 100).toString)
we now write
scala> ofType[User[Int, String]](123, (n: Int) ⇒ "id:" + (n * 100).toString)
<console>:21: Returning term: (a ⇒ b ⇒ User(a, b a)) arg1 arg2
ofType[User[Int, String]](123, (n: Int) ⇒ "id:" + (n * 100).toString)
^
res6: User[Int,String] = User(123,id:12300)
The macro ofType[T](x, y, ..., z)
generates an expression of type T
built up from the given values x
, y
, ..., z
.
The values x
, y
, ..., z
can have any type (but their type must be known or specified at that point).
Unlike implement
, the macro ofType()
is designed to be used within expressions, and so we are required to write an explicit type parameter that designates the desired result type.
The macro ofType()
will not work without specifying a type expression as its type parameter:
scala> val x: Int = ofType(123)
<console>:18: error: type <c>Int ⇒ 0 cannot be implemented
val x: Int = ofType(123)
^
Some types have more than one implementation.
There are many ways in which this can happen:
- The type involves a function with several arguments of the same type, for example
X ⇒ X ⇒ ...
. If this type can be implemented, there will be at least two implementations that differ only by the choice of the argument of typeX
. This ambiguity cannot be resolved in any reasonable way, except by making types different. - One implementation ignores some function argument(s), while another does not.
For example, the "Church numeral" type
(X ⇒ X) ⇒ X ⇒ X
can be implemented in infinitely many ways:_ ⇒ x ⇒ x
,f ⇒ x ⇒ f x
,f ⇒ x ⇒ f (f x)
, etc. Of these ways, the most likely candidate isf ⇒ x ⇒ f x
because it is the identity on the typeX ⇒ X
, which was most likely what is intended here. The implementation_ ⇒ x ⇒ x
should be probably rejected because it ignores some part of the given input. - Some arguments can be used more than once, and different implementations use them differently. Again, the "Church numerals" are an example.
The type
(X ⇒ X) ⇒ X ⇒ X
can be implemented asf ⇒ x ⇒ f x
or asf ⇒ x ⇒ f (f x)
or asf ⇒ x ⇒ f (f (f x))
, etc. There are infinitely many possible implementations that differ in how many times the argumentf
was used. In this example, probably the desired implementation is that which usesf
only once. - The type involves a tuple or a case class with several parts of the same type.
Implementing such a type will always have an ordering ambiguity.
For example, the type
(X, X, X) ⇒ (X, X, X)
can be implemented asa ⇒ (a._1, a._2, a._3)
or asa ⇒ (a._2, a._1, a._3)
or asa ⇒ (a._1, a._1, a._1)
, etc. In this example, the first implementation (preserving the ordering) is probably the desired one. It is, however, not clear what implementation is desired for a type such as(X, X, X, X) ⇒ (X, X)
. - The type involves a disjunction with several parts of the same type.
Implementing such a type will always have an ordering ambiguity.
For example, the type
Either[X, X] ⇒ Either[X, X]
can be implemented in four ways that differ by the ordering of the parts of the disjunctions:
def f1[X]: Either[X, X] ⇒ Either[X, X] = { // identity[Either[X, X]]
case Left(x) ⇒ Left(x)
case Right(x) ⇒ Right(x)
}
def f2[X]: Either[X, X] ⇒ Either[X, X] = { // switch left and right
case Left(x) ⇒ Right(x)
case Right(x) ⇒ Left(x)
}
def f3[X]: Either[X, X] ⇒ Either[X, X] = { // always return left
case Left(x) ⇒ Left(x)
case Right(x) ⇒ Left(x)
}
def f4[X]: Either[X, X] ⇒ Either[X, X] = { // always return right
case Left(x) ⇒ Right(x)
case Right(x) ⇒ Right(x)
}
In this example, the implementation f1
is probably the desired one.
The curryhoward
library implements the "information loss" heuristic for choosing the "most sensible" implementation when there are several possibilities.
As an example, consider the map
function for the State monad:
scala> def map[S, A, B]: (S ⇒ (A, S)) ⇒ (A ⇒ B) ⇒ (S ⇒ (B, S)) = implement
<console>:18: warning: type (S ⇒ Tuple2[A,S]) ⇒ (A ⇒ B) ⇒ S ⇒ Tuple2[B,S] has 2 implementations (laws need checking?):
a ⇒ b ⇒ c ⇒ Tuple2(b a c._1, a c._2) [score: (0,0,0,2,2)];
a ⇒ b ⇒ c ⇒ Tuple2(b a c._1, c) [score: (0,10000,0,1,1)].
def map[S, A, B]: (S ⇒ (A, S)) ⇒ (A ⇒ B) ⇒ (S ⇒ (B, S)) = implement
^
<console>:18: Returning term: a ⇒ b ⇒ c ⇒ Tuple2(b a c._1, a c._2)
def map[S, A, B]: (S ⇒ (A, S)) ⇒ (A ⇒ B) ⇒ (S ⇒ (B, S)) = implement
^
map: [S, A, B]=> (S => (A, S)) => ((A => B) => (S => (B, S)))
The warning shows that there exist two inequivalent implementations of the map
function.
The first implementation was automatically chosen and returned as code.
The difference between the two implementations is that the initial state s: S
can be either transformed using the given function S ⇒ (A, S)
, or it can be left unchanged and returned in the pair (B, S)
.
The first implementation is the correct functor instance for the State monad.
The second implementation "loses information" because the transformed value of type S
has been computed but then ignored.
The warning message lists all found implementations together with their "information loss score"
It appears that information-losing functions are less likely to be useful in practice. The implementation having the smallest "information loss score" will be more likely, for instance, to satisfy equational laws.
In the hopes of producing a sensible and useful answer, curryhoward
will choose the implementation with the smallest information loss score.
If there are several such implementations then no automatic choice is possible, and the macro will generate a compile-time error:
scala> def ff[A, B]: A ⇒ A ⇒ (A ⇒ B) ⇒ B = implement
<console>:18: error: type A ⇒ A ⇒ (A ⇒ B) ⇒ B can be implemented in 2 inequivalent ways:
a ⇒ b ⇒ c ⇒ c b [score: (1,0,0,0,0)];
a ⇒ b ⇒ c ⇒ c a [score: (1,0,0,0,0)].
def ff[A, B]: A ⇒ A ⇒ (A ⇒ B) ⇒ B = implement
^
In this case, allOfType[]()
might be useful.
The macro allOfType
will find the implementations that have the lowest information loss, and return a sequence of these implementations if there are more than one.
User's code can then examine each of them and check laws or other properties, selecting the implementation with the desired properties.
As a simple example, consider a function of type Int ⇒ Int ⇒ Int
:
scala> val fs = allOfType[Int ⇒ Int ⇒ Int]
<console>:18: Returning term: a ⇒ b ⇒ b
val fs = allOfType[Int ⇒ Int ⇒ Int]
^
<console>:18: Returning term: a ⇒ b ⇒ a
val fs = allOfType[Int ⇒ Int ⇒ Int]
^
fs: Seq[io.chymyst.ch.Function1Lambda[Int,Int => Int]] = List(<function1>, <function1>)
scala> fs.map(f ⇒ f(1)(2))
res7: Seq[Int] = List(2, 1)
The list fs
contains the two chosen implementations, both of them having equal levels of "information loss".
These two implementations differ on which of the Int
values is chosen as the result, the first one or the second one.
This is clear by looking at the function code printed above.
Both implementations could be desirable in different circumstances. User code can be written to examine all available implementations and to select a desired one (at run time).
Function | Type | Comment |
---|---|---|
implement |
[T] ⇒ T |
implement an expression of type specified at the left-hand side only works when there is a single good implementation |
ofType[T] |
[T] ⇒ T |
implement an expression of type specified as the type parameter; if the given type is a function type, attach the lambda-term to the function |
ofType[T](x...) |
[T] ⇒ Any* ⇒ T |
implement an expression of type specified as the type parameter, using given values x... if necessary |
allOfType[T] |
[T] ⇒ Seq[T] |
return all least-lossy implementations for an expression of type specified as the type parameter; if the given type is a function type, attach the lambda-terms to the resulting functions |
allOfType[T](x...) |
[T] ⇒ Any* ⇒ Seq[T] |
return all least-lossy implementations for an expression of type specified as the type parameter; the given values x... can be used if necessary |
anyOfType[T](x...) |
[T] ⇒ Any* ⇒ Seq[T] |
return all implementations (including arbitrarily lossy ones) for an expression of type specified as the type parameter; the given values x... can be used if necessary |
When curryhoward
generates Scala code from logical proofs, it first converts proofs into code in an intermediate programming language, called the simply-typed lambda-calculus (STLC).
The STLC language is a very small language that is precisely sufficient for representing code that is auto-generated from logical proofs.
Converting from STLC to Scala is a purely mechanical and very straightforward task.
The curryhoward
library provides a DSL and a symbolic evaluator for working with STLC terms (also called "lambda-terms").
An API is provided to inspect lambda-terms generated by implement
, anyOfType
, allOfType
, and ofType
methods.
Lambda-terms are values of type TermExpr
, which is a sealed trait with case classes such as VarE
, AppE
, ConjunctE
and so on,
representing the different constructions of the simply-typed lambda-calculus (STLC).
All values of type TermExpr
have the method .t
that yields their type as a TypeExpr
value.
TypeExpr
is another sealed trait with case classes such as BasicT
(representing a concrete, non-parameter type), DisjunctT
(representing a disjunction type) and so on.
The version of STLC used in curryhoward
includes the following features:
- variables, curried anonymous functions, function applications (this is standard in lambda-calculus)
- type expressions distinguish between type variables and ground types
- named conjunctions and named disjunctions; Scala tuples are named as in the standard library (e.g.
Tuple2
with accessors_1
,_2
) - Java-style argument groups (e.g.
def f(x: A, y: B, z: C): D
) are supported as unnamed conjunctions, as opposed to tupled argumentsdef f(t: (A, B, C)): D
or curried functionsdef f: A ⇒ B ⇒ C ⇒ D
The syntax of STLC uses non-standard conventions for disjunctions:
- Disjunction types must be named, and each part of a disjunction must be a named conjunction (or a recursive type).
- Disjunction types can be printed in the short form or in the verbose form with all top-level names, for example the type
Either[Int, A]
is printed asEither[<c>Int,A]
in the short form (.prettyPrint
) and asEither[<c>Int,A]{Left[<c>Int,A] + Right[<c>Int,A]}
in the verbose form (.prettyPrintVerbose
). - Disjunction values are printed in the notation
x + 0
or0 + x + 0
, etc., where0
signifies the missing parts of the disjunction. For example, the concrete valueLeft(x)
is printed asLeft(x) + 0
. - The
match / case
expression is printed asx match {a ⇒ ...; b ⇒ ...}
where each function aftermatch
represents a differentcase
clause.
When the macros implement
, anyOfType
, allOfType
, or ofType
are used to create a function type such as A ⇒ B ⇒ C
, the returned value is actually of a special subclass of Function1
, Function2
, or Function3
.
These subclasses are called Function1Lambda
, Function2Lambda
, Function3Lambda
and carry not only the function's compiled code but also the STLC term corresponding to the function's code.
An STLC term is generated only if an expression is generated of a pure function type (not function applied to arguments) using implement
, ofType[...]
, allOfType[...]
, or anyOfType[...]()
without value arguments.
The STLC term can be extracted using one of these two methods:
TermExpr.lambdaTerm : Any ⇒ Option[TermExpr]
. This is a safe way of finding out whether an expression has an associated STLC term.- A syntax extension
.lambdaTerm
onAny
. This is unsafe, since it will throw an exception if an expression does not have an associated STLC term.
Consider the function of type Int ⇒ Int ⇒ Int
whose implementations we have just computed as fs
.
Let us now look at the STLC terms corresponding to these implementations:
scala> fs(0).lambdaTerm
res8: io.chymyst.ch.TermExpr = \((a:<c>Int) ⇒ (b:<c>Int) ⇒ b)
scala> fs(1).lambdaTerm
res9: io.chymyst.ch.TermExpr = \((a:<c>Int) ⇒ (b:<c>Int) ⇒ a)
There are several ways in which we can use lambda-terms:
- print them in shorter or in longer form:
scala> fs(0).lambdaTerm.prettyPrint
res10: String = a ⇒ b ⇒ b
scala> fs(0).lambdaTerm.toString
res11: String = \((a:<c>Int) ⇒ (b:<c>Int) ⇒ b)
- perform symbolic computations with the lambda-terms, e.g. apply functions to arguments and simplify the resulting terms
- create fresh new symbolic variables of arbitrary types, as well as new symbolic lambda-terms (e.g. functions), for use in STLC computations
- check whether two lambda-terms are (syntactically) identical after simplification and renaming variables
To illustrate these facilities, let us determine which of the implementations fs(0)
or fs(1)
satisfies the law f(x)(y) = x
for all x
and y
.
We could write unit tests (e.g. using scalacheck
) for verifying this law on specific values of x
and y
, but this will take longer and will never give the same level of assurance as a symbolic mathematical proof.
To perform this symbolic computation, we need to create two symbolic variables x
and y
of type Int
.
(Note that in STLC all expressions and all variables must have assigned types.)
Creating symbolic variables with known types is easy with the macro freshVar
:
scala> val x = freshVar[Int]
x: io.chymyst.ch.VarE = x$1
scala> val y = freshVar[Int]
y: io.chymyst.ch.VarE = y$2
Now we can apply fs(0)
and fs(1)
to these variables and obtain the resulting symbolic terms:
scala> val results = fs.map ( f ⇒ f.lambdaTerm(x)(y) )
results: Seq[io.chymyst.ch.TermExpr] = List(((\((a:<c>Int) ⇒ (b:<c>Int) ⇒ b) x$1) y$2), ((\((a:<c>Int) ⇒ (b:<c>Int) ⇒ a) x$1) y$2))
Note that the results are unevaluated STLC terms representing function applications:
scala> results.map(_.prettyPrint)
res12: Seq[String] = List((a ⇒ b ⇒ b) x$1 y$2, (a ⇒ b ⇒ a) x$1 y$2)
We can use the .simplify
method to perform symbolic evaluation of these terms:
scala> results.map(_.simplify)
res13: Seq[io.chymyst.ch.TermExpr] = List(y$2, x$1)
To determine whether the required law holds, we can use the .equiv
method that automatically performs simplification:
scala> results.filter(r ⇒ r equiv x)
res14: Seq[io.chymyst.ch.TermExpr] = List(((\((a:<c>Int) ⇒ (b:<c>Int) ⇒ a) x$1) y$2))
This leaves only one implementation that satisfies the law.
We can now rewrite this computation working directly on the functions fs(0)
and fs(1)
and selecting the one that satisfies the law:
scala> val goodF = fs.find { f ⇒ x equiv f.lambdaTerm(x)(y) }.get
goodF: io.chymyst.ch.Function1Lambda[Int,Int => Int] = <function1>
Now we can use the good implementation:
scala> goodF(123)(456)
res15: Int = 123
Symbolic computations work also on expressions with type parameters, with some caveats. The main limitation is that Scala values cannot be parameterized by types. Also, the STLC is not polymorphic and does not support type variables directly and naturally. Nevertheless, these limitations can be overcome with some more work.
To consider an easy example, let us generate the functor method fmap
for the parameterized type Either[Int, T]
, and then verify the identity law using symbolic computations with lambda-terms.
We begin by auto-generating fmap
using the implement
macro:
scala> def fmap[A, B]: (A ⇒ B) ⇒ Either[Int, A] ⇒ Either[Int, B] = implement
<console>:18: Returning term: a ⇒ b ⇒ b match { c ⇒ (Left(c.value) + 0); d ⇒ (0 + Right(a d.value)) }
def fmap[A, B]: (A ⇒ B) ⇒ Either[Int, A] ⇒ Either[Int, B] = implement
^
fmap: [A, B]=> (A => B) => (Either[Int,A] => Either[Int,B])
scala> val fmapT = fmap.lambdaTerm // No need to specify type parameters here.
fmapT: io.chymyst.ch.TermExpr = \((a:A ⇒ B) ⇒ (b:Either[<c>Int,A]) ⇒ (b match { \((c:Left[<c>Int,A]) ⇒ (Left(c.value) + 0)); \((d:Right[<c>Int,A]) ⇒ (0 + Right((a d.value))))}))
scala> fmapT.prettyPrint
res16: String = a ⇒ b ⇒ b match { c ⇒ (Left(c.value) + 0); d ⇒ (0 + Right(a d.value)) }
scala> fmapT.t.prettyPrint // The type of fmap.
res17: String = (A ⇒ B) ⇒ Either[<c>Int,A] ⇒ Either[<c>Int,B]
We have thus extracted the STLC term fmapT
corresponding to the generated code of fmap
.
Every STLC term has a fixed assigned type, obtained as the .t()
method.
Also note that the type parameter names A
and B
in the term fmapT
are fixed by the macro implement
at compile time.
While the Scala method fmap
has type parameters and can be called fmap[Int, String]
and so on at run time,
the STLC term fmapT
has fixed STLC type names A
and B
, which we cannot change by specifying Scala type parameters at run time.
For this reason, we will have to manipulate these names explicitly in our symbolic computations.
The identity law is fmap id = id
. To verify this law, we need to apply fmap
to an identity function of type A ⇒ A
, and to check that the result is an identity function of type Either[Int, A] ⇒ Either[Int, A]
.
We can create an identity function by first creating an STLC variable of type A
, and then creating a function expression using the operator =>:
:
scala> def a[A] = freshVar[A]
a: [A]=> io.chymyst.ch.VarE
scala> val idA = a =>: a
idA: io.chymyst.ch.TermExpr = \((a$3:A) ⇒ a$3)
The type parameter name A
is fixed in the variable a
since it is defined at compile time by the freshVar
macro.
For the same reason, calling a[Int]
will return the same variable, still having type A
.
Repeated calls to a
will also return the same variable.
The operator =>:
is right-associative:
scala> val b = freshVar[Int]
b: io.chymyst.ch.VarE = b$4
scala> a =>: b =>: a
res18: io.chymyst.ch.TermExpr = \((a$3:A) ⇒ \((b$4:<c>Int) ⇒ a$3))
Identity functions are often required when checking algebraic laws.
For convenience, the TermExpr.id()
and typeExpr
methods are provided, so that we could define idA
like this:
scala> def idA[A] = TermExpr.id(typeExpr[A])
idA: [A]=> io.chymyst.ch.TermExpr
The macro typeExpr[...]
returns the STLC type expression corresponding to the specified Scala type.
Let us now apply the lambda-term fmapT
to idA
. Our first try fails:
scala> val result = fmapT(idA)
java.lang.Exception: Internal error: Invalid head type in application (\((a:A ⇒ B) ⇒ (b:Either[<c>Int,A]) ⇒ (b match { \((c:Left[<c>Int,A]) ⇒ (Left(c.value) + 0)); \((d:Right[<c>Int,A]) ⇒ (0 + Right((a d.value))))})) \((x:A) ⇒ x)): (A ⇒ B) ⇒ Either[<c>Int,A] ⇒ Either[<c>Int,B] must be a function with argument type A ⇒ A
at io.chymyst.ch.AppE.<init>(TermExpr.scala:661)
at io.chymyst.ch.TermExpr.apply(TermExpr.scala:425)
at io.chymyst.ch.TermExpr.apply$(TermExpr.scala:422)
at io.chymyst.ch.CurriedE.apply(TermExpr.scala:682)
... 48 elided
We got an error because fmapT
expects an argument of type A ⇒ B
, while we gave it the argument idA
of type A ⇒ A
.
In Scala, the compiler would have automatically set B = A
and resolved the types.
But the STLC evaluator does not support type variables directly in this way.
We need to reassign the type variable B
into A
within the term fmapT
when we apply fmapT
to idA
.
The general methods .substTypeVar
and .substTypeVars
are available for this purpose; however, these methods are somewhat cumbersome to use.
To do the type variable reassignment easier, we can use the method :@
like this:
scala> val f2 = fmapT :@ idA
f2: io.chymyst.ch.TermExpr = (\((a:A ⇒ A) ⇒ (b:Either[<c>Int,A]) ⇒ (b match { \((c:Left[<c>Int,A]) ⇒ (Left(c.value) + 0)); \((d:Right[<c>Int,A]) ⇒ (0 + Right((a d.value))))})) \((x:A) ⇒ x))
scala> f2.t.prettyPrint
res19: String = Either[<c>Int,A] ⇒ Either[<c>Int,A]
You can see from the type of f2
that the type variables in fmapT
have been automatically adjusted to match the given argument idA
.
The type of f2
is Either[Int, A] ⇒ Either[Int, A]
It remains to show that the STLC term f2
is actually equal to an identity function of type Either[Int, A] ⇒ Either[Int, A]
.
The most straightforward way of verifying that f2
is an identity function is to apply it to an arbitrary term of type Either[Int, A]
and to verify that the result is the same as the initial term.
Let us define a new variable of type Either[Int, A]
and apply f2
to that variable.
scala> def optA[A] = freshVar[Either[Int, A]]
optA: [A]=> io.chymyst.ch.VarE
scala> f2(optA).simplify
res20: io.chymyst.ch.TermExpr = optA$5
We see that, after simplification, we obtain the original term optA
.
We can also check this quicker by using the .equiv()
method, which will automatically perform simplification:
scala> assert(optA equiv f2(optA))
This concludes the verification of the identity law. Here is the entire code once again, slightly shorter:
def fmap[A, B]: (A ⇒ B) ⇒ Either[Int, A] ⇒ Either[Int, B] = implement
val fmapT = fmap.lambdaTerm
def a[A] = freshVar[A]
def optA[A] = freshVar[Either[Int, A]]
assert((fmapT :@ (a =>: a))(optA) equiv optA)
We have seen that the operator :@
helps reassign type variables when checking algebraic laws.
Another often used operation that needs type variable reassignment is the function composition (the method andThen
).
The operators :@@
and @@:
perform function composition with automatic reassignment of type variables.
In the operators :@
, :@@
, and @@:
, the colon :
mnemonically shows the side that will automatically adjust its type.
To illustrate the use of these operators, consider the function pure
, which is standard for the Either
monad and can be implemented automatically:
scala> def pure[A]: A ⇒ Either[Int, A] = implement
<console>:18: Returning term: a ⇒ (0 + Right(a))
def pure[A]: A ⇒ Either[Int, A] = implement
^
pure: [A]=> A => Either[Int,A]
scala> val pureT = pure.lambdaTerm
pureT: io.chymyst.ch.TermExpr = \((a:A) ⇒ (0 + Right(a)))
Why didn't we write implement.lambdaTerm
directly?
The implement
macro needs to read its immediate outer context and so cannot be used as an expression such as implement.lambdaTerm
.
For this to work, we need to put implement
by itself at the right-hand side of a definition.
Alternatively, we could use ofType
like this:
def pureT[A] = ofType[A ⇒ Either[Int, A]].lambdaTerm
Let us verify the naturality law for the function pure
:
f . pure = pure . fmap f
In this law, f
is an arbitrary function of type A ⇒ B
. Let us therefore create a STLC variable of this type:
scala> def f[A,B] = freshVar[A ⇒ B]
f: [A, B]=> io.chymyst.ch.VarE
We will now compute both sides of the naturality equation, reassigning type variables automatically:
scala> val leftSide = f @@: pureT
leftSide: io.chymyst.ch.TermExpr = \((x1:A) ⇒ (\((a:B) ⇒ (0 + Right(a))) (f$6 x1)))
scala> val rightSide = pureT :@@ (fmapT :@ f)
rightSide: io.chymyst.ch.TermExpr = \((x2:A) ⇒ ((\((a:A ⇒ B) ⇒ (b:Either[<c>Int,A]) ⇒ (b match { \((c:Left[<c>Int,A]) ⇒ (Left(c.value) + 0)); \((d:Right[<c>Int,A]) ⇒ (0 + Right((a d.value))))})) f$6) (\((a:A) ⇒ (0 + Right(a))) x2)))
scala> assert(leftSide equiv rightSide)
The choice of whether to use @@:
or :@@
must be made correctly for this to work.
As a mnemonic, remember that the colon character :
shows which side of the composition will adjust its type parameters.
Symbolic verification of laws may require constructing arbitrary lambda-terms. For instance, it may be necessary to manipulate lambda-terms corresponding to tuples, case classes, and sealed traits, and perform operations such as matching on a case class within a sealed trait or accessing a named field in a case class.
The extension of STLC supported by curryhoward
supports sealed traits and case classes via "named conjunctions" and "named disjunctions".
A case class is represented as a named conjunction, that is, a conjunction that has a name for each part, and also a name for itself. For example, consider the following case class:
scala> final case class User(fullName: String, id: Long)
defined class User
This type is equivalent to a conjunction of types String
and Long
.
However, the type User
has a name User
for itself, and each part of the conjunction also has a name (in this example, these names are fullName
and id
).
A sealed trait with one or more case classes is represented as a named disjunction, that is, a disjunction that has a name for each part, and also a name for itself.
For example, the type Either[Int, String]
is represented as a disjunction of types Int
and String
. The disjunction is named because it has the name Either
for itself and also the names Left
and Right
for the two parts of the disjunction.
Sealed traits sometimes contain case objects (or case classes with no arguments).
These types are analogous to the Unit
type, except that they have a name.
We will refer to these types as "named Unit
" types, and regard them as named conjunctions having zero parts.
For manipulating conjunction and disjunction types, the current API of the curryhoward
library provides the following facilities:
- create a named conjunction from given parts
- decompose a given named conjunction into parts
- create a named disjunction from a given part
- match on a given named disjunction, mapping each part via a given function
To illustrate these facilities, let us construct a lambda-term representing a function getId
of type Option[User] ⇒ Option[Long]
and apply that function to some test data.
We will then implement this function automatically using the curryhoward
library, and compare the resulting lambda-terms.
We begin by creating a fresh variable of type Option[User]
.
scala> val ou = freshVar[Option[User]]
ou: io.chymyst.ch.VarE = ou$7
The type Option[User]
is a disjunction type having two parts: None.type
and Some[User]
.
We would like to define the lambda-term getId
like this, val getId = u =>: ...
The body of the function getId
must examine the value of u
and match on the two parts of the disjunction.
To create the function body, we need to create a lambda-term that matches the value of u
on the two parts of the disjunction.
In Scala, we would have written
(ou: Option[User]) ⇒ ou match {
case None ⇒ None
case Some(User(fullName, id)) ⇒ Some(id)
}
Now we are going to translate this code into lambda-terms.
Both case
expressions are represented by functions.
The argument types of these functions are None.type
and Some[User]
.
Therefore, the next step for us is to create these functions as lambda-terms.
For that, we will need to create new fresh variables of these types.
scala> val n = freshVar[None.type]
n: io.chymyst.ch.VarE = n$8
scala> val su = freshVar[Some[User]]
su: io.chymyst.ch.VarE = su$9
We now need to create the case clauses. We might imagine to write them like this:
val case1 = n =>: ???
val case2 = su =>: ???
The first case clause is simple: it takes a value None
of type Option[User]
and returns a value None
of type Option[Long]
.
However, we must take care to assign correct types to all terms.
So we cannot just write val case1 = n =>: n
because that function would have the return type None.type
rather than Option[Long]
.
We need to inject None.type
into the disjunction Option[Long]
.
This is done in three steps:
- create a type expression
ol
for the Scala typeOption[Long]
- create a value of type
None.type
usingn.t()
-- note thatNone.type
is essentially a "namedUnit
", and we can always create values of aUnit
type with no extra data required - using the
apply
method of the type expressionol
, lift the value of typeNone.type
into the disjunction typeOption[Long]
scala> val ol = typeExpr[Option[Long]]
ol: io.chymyst.ch.DisjunctT = DisjunctT(Option,List(BasicT(Long)),List(NamedConjunctT(None,List(),List(),List()), NamedConjunctT(Some,List(BasicT(Long)),List(value),List(BasicT(Long)))))
scala> val case1 = n =>: ol(n.t())
case1: io.chymyst.ch.TermExpr = \((n$8:None.type) ⇒ (<co>None() + 0))
Instead of defining ol
as a type expression, we could have defined it as a freshVar[]
and accessed the variable's type expression as .t
.
When we do not need a variable separately, we can make a type expression directly using typeExpr[]
.
To implement the second case clause, we need to decompose s
of type Some[User]
.
In STLC, the type Some[User]
is a named conjunction consisting of a single part (which is of type User
).
The type User
is itself a named conjunction consisting of two parts.
To access a specific part of a conjunction, we can use the .apply
method with a zero-based index or an accessor name.
Let us use the index 0
to access the value of Some
, and the name "id"
to access the part of the User
value.
Thus, the user's id
is accessed as s(0)("id")
.
It remains to construct the value of type Option[Long]
out of the user's id
.
This is done using the following steps:
- create a STLC type expression for the Scala type
Some[Long]
- using that type expression's
apply
method, create a named conjunction of typeSome[Long]
containingid
as its only part - inject that value into the disjunction type
Option[Long]
scala> val sl = typeExpr[Some[Long]]
sl: io.chymyst.ch.NamedConjunctT = NamedConjunctT(Some,List(BasicT(Long)),List(value),List(BasicT(Long)))
scala> val case2 = su =>: ol(sl(su(0)("id")))
case2: io.chymyst.ch.TermExpr = \((su$9:Some[User]) ⇒ (0 + Some(su$9.value.id)))
Now we are ready to write the match statement, which is done by using the .cases
function on the disjunction value u
:
scala> val getId = ou =>: ou.cases(case1, case2)
getId: io.chymyst.ch.TermExpr = \((ou$7:Option[User]) ⇒ (ou$7 match { \((n$8:None.type) ⇒ (<co>None() + 0)); \((su$9:Some[User]) ⇒ (0 + Some(su$9.value.id)))}))
scala> getId.prettyPrint
res23: String = ou$7 ⇒ ou$7 match { n$8 ⇒ (None() + 0); su$9 ⇒ (0 + Some(su$9.value.id)) }
Let us now apply this function term to some data and verify that it works as expected.
In STLC there are no concrete values of type String
or Long
; so we need to use fresh variables instead.
When constructing conjunction and disjunction terms, we may use a shortcut -- call .apply
on the fresh variables themselves, rather than on their type expressions.
So, dUser(dString, dLong)
is the same as dUser.t(dString, dLong)
and constructs a new named conjunction term of type User
.
scala> val dString = freshVar[String]
dString: io.chymyst.ch.VarE = dString$10
scala> val dLong = freshVar[Long]
dLong: io.chymyst.ch.VarE = dLong$11
scala> val u = freshVar[User]
u: io.chymyst.ch.VarE = u$12
scala> val data = ou(su(u(dString, dLong)))
data: io.chymyst.ch.TermExpr = (0 + Some(User(dString$10, dLong$11)))
scala> val result1 = getId(data).simplify
result1: io.chymyst.ch.TermExpr = (0 + Some(dLong$11))
We have obtained the resulting term, and we can see that it is what we expected -- it represents Some(dLong)
as the right part of the disjunction type Option[Long]
.
We will now check that the same lambda-term is obtained when implementing the function getId
automatically using curryhoward
.
scala> val getIdAuto: Option[User] ⇒ Option[Long] = implement
<console>:20: warning: type Option[User] ⇒ Option[<c>Long] has 2 implementations (laws need checking?):
a ⇒ a match { b ⇒ (None() + 0); c ⇒ (0 + Some(c.value.id)) } [score: (0,15000,0,0,0)];
a ⇒ (None() + 0) [score: (1,0,0,0,0)].
val getIdAuto: Option[User] ⇒ Option[Long] = implement
^
<console>:20: Returning term: a ⇒ a match { b ⇒ (None() + 0); c ⇒ (0 + Some(c.value.id)) }
val getIdAuto: Option[User] ⇒ Option[Long] = implement
^
getIdAuto: Option[User] => Option[Long] = <function1>
scala> val getIdAutoTerm = getIdAuto.lambdaTerm
getIdAutoTerm: io.chymyst.ch.TermExpr = \((a:Option[User]) ⇒ (a match { \((b:None.type) ⇒ (<co>None() + 0)); \((c:Some[User]) ⇒ (0 + Some(c.value.id)))}))
scala> getIdAutoTerm.prettyPrint
res24: String = a ⇒ a match { b ⇒ (None() + 0); c ⇒ (0 + Some(c.value.id)) }
scala> getId.prettyPrint
res25: String = ou$7 ⇒ ou$7 match { n$8 ⇒ (None() + 0); su$9 ⇒ (0 + Some(su$9.value.id)) }
The prettyRename
method will rename all variables in a given term to names a
, b
, c
, and so on.
Note that prettyRenamePrint
performs a prettyRename
before printing the term, and that the macros implement
and ofType
will always perform prettyRename
as well, before returning a term.
For terms we constructed ourselves, such as getId
, we need to run prettyRename
so that the term becomes syntactically equal to getIdAutoTerm
.
The method equiv
will do this automatically:
scala> getIdAutoTerm equiv getId.prettyRename
res26: Boolean = true
Function | Type | Comment |
---|---|---|
TermExpr.lambdaTerm |
Any ⇒ Option[TermExpr] |
extract a lambda-term if present |
a.lambdaTerm |
Any ⇒ TermExpr |
extract a lambda-term, throw exception if not present |
t.prettyPrint |
TermExpr ⇒ String and TypeExpr ⇒ String |
produce a more readable string representation than .toString |
t.prettyPrintVerbose |
TypeExpr ⇒ String |
produce a more verbose string representation for disjunction types |
t.prettyRename |
TermExpr ⇒ TermExpr |
rename all variables in a term to a , b , c , etc., so that the term becomes more readable |
t.prettyRenamePrint |
TermExpr ⇒ String |
shorthand for .prettyRename.prettyPrint |
a.t |
TermExpr ⇒ TypeExpr |
get the type expression for a given term |
typeExpr[T] |
type T |
create a STLC type expression representing the Scala type T -- here T can be a Scala type parameter or a Scala type expression such as Int ⇒ Option[A] |
freshVar[T] |
VarE |
create a STLC variable with assigned Scala type expression T -- here T can be a Scala type parameter or a Scala type expression such as Int ⇒ Option[A] ; the name of the variable will be created automatically; shortcut for VarE("name", typeExpr[T]) |
a =>: b |
VarE ⇒ TermExpr ⇒ TermExpr |
create a STLC function term (lambda-calculus "abstraction" operation) |
a(b) |
TermExpr ⇒ TermExpr ⇒ TermExpr |
create a STLC "application" term -- the type of a must be a function and the type of b must be the same as the argument type of a |
a :@ b |
TermExpr ⇒ TermExpr ⇒ TermExpr |
create a STLC "application" term with automatic substitution of type variables in a -- the type of a must be a function and the type of b must be the same as the argument type of a after some type variables in a have been substituted |
a andThen b |
TermExpr ⇒ TermExpr ⇒ TermExpr |
compose functions a and b -- the argument type of b must be the same as the result type of a ; no substitution of type variables is performed |
a :@@ b |
TermExpr ⇒ TermExpr ⇒ TermExpr |
compose functions a and b -- the argument type of b must be the same as the result type of a after an automatic substitution of type variables in a |
a @@: b |
TermExpr ⇒ TermExpr ⇒ TermExpr |
compose functions a and b -- the argument type of b must be the same as the result type of a after an automatic substitution of type variables in b |
a.simplify |
TermExpr ⇒ TermExpr |
perform symbolic evaluation and simplification of STLC term -- the simplification is recursive and repeated, so that a.simplify.simplify is guaranteed to be the same as a.simplify |
a equiv b |
TermExpr ⇒ TermExpr ⇒ Boolean |
check whether two terms are syntactically equal after simplification and prettyRename |
a.substTypeVar(b, c) |
TermExpr ⇒ (TermExpr, TermExpr) ⇒ TermExpr |
replace a type variable in a ; the type variable is specified as the type of b , and the replacement type is specified as the type of c |
a.substTypeVars(s) |
TermExpr ⇒ Map[TP, TypeExpr] ⇒ TermExpr |
replace all type variables in a according to the given substitution map s -- all type variables are substituted at once |
u() |
TermExpr ⇒ () ⇒ TermExpr and TypeExpr ⇒ () ⇒ TermExpr |
create a "named Unit" term of type u.t -- the type of u must be a named unit type, e.g. None.type or a case class with no constructors |
c(x...) |
TermExpr ⇒ TermExpr* ⇒ TermExpr and TypeExpr ⇒ TermExpr* ⇒ TermExpr |
create a named conjunction term of type c.t -- the type of c must be a conjunction whose parts match the types of the arguments x... |
d(x) |
TermExpr ⇒ TermExpr ⇒ TermExpr and TypeExpr ⇒ TermExpr ⇒ TermExpr |
create a disjunction term of type d.t using term x -- the type of x must match one of the disjunction parts in the type d , which must be a disjunction type |
c(i) |
TermExpr ⇒ Int ⇒ TermExpr |
project a conjunction term onto part with given zero-based index -- the type of c must be a conjunction with sufficiently many parts |
c("id") |
TermExpr ⇒ String ⇒ TermExpr |
project a conjunction term onto part with given accessor name -- the type of c must be a named conjunction that supports this accessor |
d.cases(x =>: ..., y =>: ..., ...) |
TermExpr ⇒ TermExpr* ⇒ TermExpr |
create a term that pattern-matches on the given disjunction term -- the type of d must be a disjunction whose arguments match the arguments x , y , ... of the given case clauses |