Note: some words can be hovered over! They don't have any different formatting from other things.
This is a simple? guide on understanding a very small implementation of
Prolog
in Haskell.
I cannot be sure if this will be similar to any serious Prolog implementation,
but I can tell you that this is based primarily off
NanoProlog,
To start off, we can first instantiate the data of some grammar in Prolog.
import qualified Data.Map as M
type Sym = String -- a symbol
data Term =
Var Sym -- X, Hello
| Fun Sym [Term] -- true, test(X, Y), bob
deriving Eq
data Rule =
Term -- head function; any head must be true for function to be
:- [Term] -- clauses; all must be true for head to be
deriving Eq
type Env = M.Map Sym Term -- variables, and what they stand for (bindings)
data Res =
Yes Env -- goal is satisfied under these bindings
| Do [(Rule, Res)] -- for each rule listed, using the rule...
deriving Eq
Now, let's write our first function,
subst.
Given a binding environment and a term, it replaces all variables in the term with functions using the bindings.
It does so as much as it can (so not simply looking it up once), and also recursively.
subst :: Env -> Term -> Term -- substitiution
subst e v@(Var x) = maybe v (subst e) (M.lookup x e)
subst e (Fun x cs) = Fun x (map (subst e) cs)
Since M.lookup is of type Maybe, we can use that to fall back in case of failure.
This allows us to repeat lookups multiple times, so that the result, if it is there, is in a sense "atomic".
Next up is
unify.
Given two terms and a binding environment, it may, if the two terms could be equal to each other,
create a new environment, adding the required bindings to do so.
import Data.Foldable (foldrM)
unify :: (Term, Term) -> Env -> Maybe Env
unify (t, u) e = subst e t ? subst e u where
(?) :: Term -> Term -> Maybe Env
Var x ? y = Just (M.insert x y e)
x ? Var y = Just (M.insert y x e)
Fun x xc ? Fun y yc
| x == y && length xc == length yc
= foldrM unify e (zip xc yc)
_ ? _ = Nothing
Let me walk through this one.
- Both terms are turned into a substituted form, so the function can deal with just free variables, so it can bind them.
- If one of the terms is a variable, then it can be binded to the other term. If this is the case, an environment can be returned that is just the give one with the extra binding.
-
If both terms are the same function with the same amount of inputs,
There is an attempt to unify each corresponding pair of arguments,
and make sure they all are able to equal each other.
-
The action that
does is effectively:foldrM(which actually uses foldlin its definition!)let [(a, a'), (b, b')...(y, y'), (z, z')] = zip xc yc in do e0 <- unify (z, z') e e1 <- unify (y, y') e0 ... e'1 <- unify (b, b') e'2 e' <- unify (a, a') e'1 e' -
If any of the calls to
unifyinside of?cannot return an environment,?can't bind that argument, so it can't bind the function either.
-
The action that
- If none of the above is true, then nothing else can be done, so an environment is not returned.
However, perhaps we want to make sure we don't get into infinite loops like:
X :- X.
f(X) :- f(f(X)).
To prevent this, we can check recursively for whether
the variable to be binded
occurs
in the other term:
(-?>) :: Sym -> Term -> Bool -- occurs-check (negated)
x -?> Var y = x /= y
x -?> Fun _ y = all (x -?>) y
unify :: (Term, Term) -> Env -> Maybe Env
unify (t, u) e = subst e t ? subst e u where
(?) :: Term -> Term -> Maybe Env
Var x ? y | x -?> y = Just (M.insert x y e)
x ? Var y | y -?> x = Just (M.insert y x e)
Fun x xc ? Fun y yc
| x == y && length xc == length yc
= foldrM unify e (zip xc yc)
_ ? _ = Nothing
Now for the function with the biggest job:
solve.
Given a set of rules to use, a set of terms to prove, and a binding enviroment,
If unify was the rising action, this is the climax.
import Data.Maybe (catMaybes)
solve :: [Rule] -> [Term] -> Env -> Res
solve _ [] e = Yes e
solve rs (t:ts) e = Do (catMaybes
[(r,) . solve rs <$> unify (t, c) e | r@(c :- cs) <- rs])
This is really concise, so let's expand it.
solve :: [Rule] -> [Term] -> Env -> Res
solve _ [] env = Yes env
solve rules (term : rest_terms) env =
Do [x | Just x <- [do
new_env <- unify (term, head) env
Just (this_rule, solve rules (body ++ rest_terms) new_env)
| this_rule@(head :- body) <- rules]]
I think I will leave the understanding of this function as an exercise for the reader.
However, what I won't leave out is how there is a major flaw with how
the program is currently written, back when M.insert was used.
If a variable has the same name as a differently binded variable, which
can happen for example in a recursive function, the newer variable
overwrites the older variable.
To fix this, we use a different definition for variables, that uses "tags":
type Tag = Int
type VarD = (Sym, [Tag]) -- Var data
data Term = Var VarD | Fun Sym [Term] deriving Eq
type Env = M.Map VarD Term
class Tagged t where tag :: Tag -> t -> t -- variable renaming apart
instance Tagged Term where
tag t (Var (x, y)) = Var (x, t:y)
tag t (Fun x y) = Fun x (map (tag t) y)
instance Tagged Rule where tag t (c :- cs) = tag t c :- map (tag t) cs
This also changes how solve works.
solve :: [Rule] -> [Term] -> Tag -> Env -> Res
solve _ [] _ e = Yes e
solve rs (t:ts) tg e = Do (catMaybes
[(r,) . solve rs (cs ++ ts) (succ tg) <$> unify (t, c) e | r@(c :- cs) <- map (tag tg) rs])
solve now also increments through a "tag"
through the tree-like structure of clauses and terms, so any similarly named variables
seperated even once are marked different, and therefore treated differently.
From this point, things calm down. Next is strip, which
takes all the Yeses and compiles them,
leaving just the variable bindings.
strip :: Res -> [Env]
strip (Yes y) = [y]
strip (Do x) = x >>= strip . snd
When the result is Do, it takes all the results that can follow,
stripping them for environments, and combining the outputs together.
A set of shorthands can also be defined for using the main functions.
var x = Var (x, [])
sym x = Fun x []
fact x y = Fun x y :- []
query x y = solve x [y] 0 M.empty
Something can be noticed here: since any variable not provided by the user will be tagged at some point, that means that the user's variables can be identified by an empty tag list. We can write a function to show environments in a Prolog-like way using this, so that the user won't see any intermediate variables that were binded.
showEnv :: Env -> String
showEnv x = unlines ("YES":[v ++ " <- " ++ show (subst x (Var s)) | (s@(v, []), _) <- M.toList x])
Of course, in order to use this function, we need to write Show instances
for some other datatypes first. Along the way, we can also write a function to
sequence the strings into a sort of pager.
import Data.List (intercalate)
instance Show Term where
show (Var (x, [])) = x
show (Var (x, t)) = x ++ show t
show (Fun x []) = x
show (Fun x y) = x ++ '(' : intercalate ", " (map show y) ++ ")"
instance Show Rule where
show (x :- y) = show x ++ " :- " ++ intercalate ", " (map show y) ++ "."
page :: [String] -> IO ()
page = foldr (\x -> (putStr x >> getLine >>)) (putStrLn "NO")
While I haven't implemented Read instances yet, at least we are
now able to write programs in Prolog-like structures in Haskell!
rules = [
fact "edge" [sym "a", sym "b"],
fact "edge" [sym "b", sym "c"],
Fun "path" [var "X", var "Y"] :- [Fun "edge" [var "X", var "Y"]],
Fun "path" [var "X", var "Y"] :- [Fun "edge" [var "X", var "Z"], Fun "path" [var "Z", var "Y"]]
]
goal = Fun "path" [sym "a", var "X"]
solution = strip (query rules goal)
main = page (map showEnv solution)
~/bin/l/hs$ runghc PrologE.hs YES X <- b Enter YES X <- c Enter NO ~/bin/l/hs$
And that's it! We have made a simple Prolog interpreter that safely handles variables, unifies, and solves for goals, in 100 lines!
~/bin/l/hs$ nl Prolog*
I have split the functions into two seperate files, and there are a few extra things in those version of the files I've written, so it's probably less for you.
One thing I haven't added is negation as failure, but I suppose I will add that when I revisit this.
