Topics covered: Logic Programming, Part 1
Instructors: Hal Abelson and Gerald Jay Sussman
Subtitles for this course are provided through the generous assistance of Henry Baker, Hoofar Pourzand, Heather Wood, Aleksejs Truhans, Steven Edwards, George Menhorn, and Mahendra Kumar.
[MUSIC PLAYING BY J.S. BACH]
PROFESSOR: The last time we began having a look at how languages are constructed. Remember the main point that an evaluator for, LISP, say, has two main elements. There is EVAL, and EVAL's job is to take in an expression and an environment and turn that into a procedure and some arguments and pass that off to APPLY. And APPLY takes the procedure in the arguments, turns that back into, in a general case, another expression to be evaluated in another environment and passes that off to EVAL, which passes it to APPLY, and there's this whole big circle where things go around and around and around until you get either to some very primitive data or to a primitive procedure.
See, what this cycle has to do with is unwinding the means of combination and the means of abstraction in the language. So for instance, you have a procedure in LISP-- a procedure is a general way of saying, I want to be able to evaluate this expression for any value of the arguments, and that's sort of what's going on here. That's what APPLY does. It says the general thing coming in with the arguments reduces to the expression that's the body, and then if that's a compound expression or another procedure application, the thing will go around and around the circle. Anyway, that's sort of the basic structure of gee, pretty much any interpreter.
The other thing that you saw is once you have the interpreter in your hands, you have all this power to start playing with the language. So you can make it dynamically scoped, or you can put in normal order evaluation, or you can add new forms to the language, whatever you like. Or more generally, there's this notion of metalinguistic abstraction, which says that part of your perspective as an engineer, as a software engineer, but as an engineer in general is that you can gain control of complexity by inventing new languages sometimes. See, one way to think about computer programming is that it only incidentally has to do with getting a computer to do something. Primarily what a computer program has to do with, it's a way of expressing ideas with communicating ideas. And sometimes when you want to communicate new kinds of ideas, you'd like to invent new modes of expressing that.
Well, today we're going to apply this framework to build a new language. See, once we have the basic idea of the interpreter, you can pretty much go build any language that you like. So for example, we can go off and build Pascal. And gee, we would worry about syntax and parsing and various kinds of compiler optimizations, and there are people who make honest livings doing that, but at the level of abstraction that we're talking, a Pascal interpreter would not look very different at all from what you saw Gerry do last time.
Instead of that, we'll spend today building a really different language, a language that encourages you to think about programming not in terms of procedures, but in a really different way. And the lecture today is going to be at two levels simultaneously. On the one hand, I'm going to show you what this language looks like, and on the other hand, I'll show you how it's implemented. And we'll build an implementation in LISP and see how that works.
And you should be drawing lessons on two levels. The first is to realize just how different a language can be. So if you think that the jump from Fortran to LISP is a big deal, you haven't seen anything yet. And secondly, you'll see that even with such a very different language, which will turn out to not have procedures at all and not talk about functions at all, there will still be this basic cycle of eval and apply that's unwinds the means of combination and the means an abstraction. And then thirdly, as kind of a minor but elegant technical point, you'll see a nice use of streams to avoid backtracking.
OK, well, I said that this language is very different. To explain that, let's go back to the very first idea that we talked about in this course, and that was the idea of the distinction between the declarative knowledge of mathematics-- the definition of a square root as a mathematical truth-- and the idea that computer science talks about the how to knowledge-- contrast that definition of square root with a program to compute a square root. That's where we started off. Well, wouldn't it be great if you could somehow bridge this gap and make a programming language which sort of did things, but you talked about it in terms of truth, in declarative terms? So that would be a programming language in which you specify facts. You tell it what is. You say what is true. And then when you want an answer, somehow the language has built into it automatically general kinds of how to knowledge so it can just take your facts and it can evolve these methods on its on using the facts you gave it and maybe some general rules of logic.
So for instance, I might go up to this program and start telling it some things. So I might tell it that the son of Adam is Abel. And I might tell it that the son of Adam is Cain. And I might tell it that the son of Cain is Enoch. And I might tell it that the son of Enoch is Irad, and all through the rest of our chapter whatever of Genesis, which ends up ending in Adah, by the way, and this shows the genealogy of Adah from Cain.
Anyway, once you tell it these facts, you might ask it things. You might go up to your language and say, who's the son of Adam? And you can very easily imagine having a little general purpose search program which would be able to go through and in response to that say, oh yeah, there are two answers: the son of Adam is Abel and the son of Adam is Cain. Or you might say, based on the very same facts, who is Cain the son of? And then you can imagine generating another slightly different search program which would be able to go through and checked for who is Cain, and son of, and come up with Adam. Or you might say, what's the relationship between Cain and Enoch? And again, a minor variant on that search program. You could figure out that it said son of.
But even here in this very simple example, what you see is that a single fact, see, a single fact like the son of Adam is Cain can be used to answer different kinds of questions. You can say, who's the son of, or you can say who's the son of Adam, or you can say what's the relation between Adam and Cain? Those are different questions being run by different traditional procedures all based on the same fact.
And that's going to be the essence of the power of this programming style, that one piece of declarative knowledge can be used as the basis for a lot of different kinds of how-to knowledge, as opposed to the kinds of procedures we're writing where you sort of tell it what input you're giving it and what answer you want. So for instance, our square root program can perfectly well answer the question, what's the square root of 144? But in principle, the mathematical definition of square root tells you other things. Like it could say, what is 17 the square root of? And that would be have to be answered by a different program. So the mathematical definition, or in general, the facts that you give it are somehow unbiased as to what the question is. Whereas the programs we tend to write specifically because they are how-to knowledge tend to be looking for a specific answer. So that's going to be one characteristic of what we're talking about.
We can go on. We can imagine that we've given our language some sort of facts. Now let's give it some rules of inference. We can say, for instance, if the-- make up some syntax here-- if the son of x is y-- I'll put question marks to indicate variables here-- if the son of x is y and the son of y is z, then the grandson of x is z. So I can imagine telling my machine that rule and then being able to say, for instance, who's the grandson of Adam? Or who is Irad the grandson of? Or deduce all grandson relationships you possibly can from this information. We can imagine somehow the language knowing how to do that automatically.
Let me give you maybe a little bit more concrete example. Here's a procedure that merges two sorted lists. So x and y are two, say, lists of numbers, lists of distinct numbers, if you like, that are in increasing order. And what merge does is take two such lists and combine them into a list where everything's in increasing order, and this is a pretty easy programs that you ought to be able to write. It says, if x is empty, the answer is y. If y is empty, the answer is x. Otherwise, you compare the first two elements. So you pick out the first thing in x and the first thing in y, and then depending on which of those first elements is less, you stick the lower one on to the result a recursively merging, either chopping the first one off x or chopping the first one off y. That's a standard kind of program.
Let's look at the logic. Let's forget about the program and look at the logic on which that procedure is based. See, there's some logic which says, gee, if the first one is less, then we get the answer by sticking something onto the result of recursively merging the rest. So let's try and be explicit about what that logic is that's making the program work.
So here's one piece. Here's the piece of the program which recursively chops down x if the first thing in x is smaller. And if you want to be very explicit about what the logic is there, what's really going on is a deduction, which says, if you know that some list, that we'll call cdr of x, and y merged to form z, and you know that a is less than the first thing in y, then you know that if you put a onto the cdr of x, then that result and y merge to form a onto z. And what that is, that's the underlying piece of logic-- I haven't written it as a program, I wrote it a sort of deduction that's underneath this particular clause that says we can use the recursion there. And then similar, here's the other clause just to complete it. The other clause is based on this piece of logic, which is almost the same and I won't go through it, and then there's the n cases where we tested for null, and that's based on the idea that for any x, x and the empty list merge to form an x, or for any y, the empty list and y merge to form y.
OK, so there's a piece of procedure and the logic on which it's based. And notice a big difference. The procedure looked like this: it said there was a box-- and all the things we've been doing have the characteristic we have boxes and things going in and things going out-- there was this box called merge, and in came an x and y, and out came an answer. That's the character of the procedure that we wrote. These rules don't look like that. These rules talk about a relation. There's some sort of relation that in those slides I called mrege-to-form. So I said x and y merge to form z, and somehow this is a function. Right? The answer is a function of x and y, and here what I have is a relation between three things. And I'm not going to specify which is the input and which is the output. And the reason I want to say that is because in principle, we could use exactly those same logic rules to answer a lot of different questions.
So we can say, for instance-- imagine giving our machine those rules of logic. Not the program, the underlying rules of logic. Then it ought to be able to say-- we could ask it-- 1, 3, 7 and 2, 4, 8 merge to form what? And that's a question it ought to be able to answer. That's exactly the same question that our list procedure answered.
But the exact same rules should also be able to answer a question like this: 1, 3, 7 and what merged to form 1, 2, 3, 4, 7, 8? The same rules of logic can answer this, although the procedure we wrote can't answer that question. Or we might be able to say what and what else merge to form-- what and what else merge to form 1, 2, 3, 4, 7, 8? And the thing should be able to go through, if it really can apply that logic, and deduce all, whatever is, 2 to the sixth answers to that question. It could be 1 and the rest, or it could be 1, 2 and the rest. Or it could be 1 and 3 and 7 and the rest. There's a whole bunch of answers. And in principle, the logic should be enough to deduce that.
So there are going to be two big differences in the kind of program we're going to look at and not only list, but essentially all the programming you've probably done so far in pretty much any language you can think of. The first is, we're not going to be computing functions. We're not going to be talking about things that take input and output. We're going to be talking about relations. And that means in principle, these relations don't have directionality. So the knowledge that you specify to answer this question, that same knowledge should also allow you to answer these other questions and conversely.
And the second issue is that since we're talking about relations, these relations don't necessarily have one answer. So that third question down there doesn't have a particular answer, it has a whole bunch of answers.
Well, that's where we're going. This style of programming, by the way, is called logic programming, for kind of obvious reasons. And people who do logic programming say that-- they have this little phrase-- they say the point of logic programming is that you use logic to express what is true, you use logic to check whether something is true, and you use logic to find out what is true. The best known logic programming language, as you probably know, is called Prolog. The language that we're going to implement this morning is something we call the query language, and it essentially has the essence of prologue. It can do about the same stuff, although it's a lot slower because we're going to implement it in LISP rather than building a particular compiler. We're going to interpret it on top of the LISP interpreter. But other than that, it can do about the same stuff as prolog. It has about the same power and about the same limitations.
All right, let's break for question.
STUDENT: Yes, could you please repeat what the three things you use logic programming to find? In other words, to find what is true, learn what is true-- what is the?
PROFESSOR: Right. Sort of a logic programmer's little catechism. You use logic to express what is true, like these rules. You use logic to check whether something is true, and that's the kind of question I didn't answer here. I might say-- another question I could put down here is to say, is it true that 1, 3, 7 and 2, 4, 8 merge to form 1, 2, 6, 10 And that same logic should be enough to say no. So I use logic to check what is true, and then you also use logic to find out what's true.
All right. Let's break.
[MUSIC PLAYING BY J.S. BACH]
[MUSIC PLAYING BY J.S. BACH]
PROFESSOR: OK, let's go ahead and take a look at this query language and operation. The first thing you might notice, when I put up that little biblical database, is that it's nice to be able to ask this language questions in relation to some collection of facts. So let's start off and make a little collection of facts. This is a tiny fragment of personnel records for a Boston high tech company, and here's a piece of the personnel records of Ben Bitdiddle. And Ben Bitdiddle is the computer wizard in this company, he's the underpaid computer wizard in this company. His supervisor is all Oliver Warbucks, and here's his address.
So the format is we're giving this information: job, salary, supervisor, address. And there are some other conventions. Computer here means that Ben works in the computer division, and his position in the computer division is wizard.
Here's somebody else. Alyssa, Alyssa P. Hacker is a computer programmer, and she works for Ben, and she lives in Cambridge. And there's another programmer who works for Ben who's Lem E. Tweakit. And there's a programmer trainee, who is Louis Reasoner, and he works for Alyssa. And the big wheel of the company doesn't work for anybody, right? That's Oliver Warbucks.
Anyway, what we're going to do is ask questions about that little world. And that'll be a sample world that we're going to do logic in. Let me just write up here, for probably the last time, what I said is the very most important thing you should get out of this course, and that is, when somebody tells you about a language, you say, fine-- what are the primitives, what are the means of combination, how do you put the primitives together, and then how do you abstract them, how do you abstract the compound pieces so you can use them as pieces to make something more complicated? And we've said this a whole bunch of times already, but it's worth saying again.
Let's start. The primitives. Well, there's really only one primitive, and the primitive in this language is called a query. A primitive query. Let's look at some primitive queries. Job x. Who is a computer programmer? Or find every fact in the database that matches job of the x is computer programmer. And you see a little syntax here. Things without question marks are meant to be literal, question mark x means that's a variable, and this thing will match, for example, the fact that Alyssa P. Hacker is a computer programmer, or x is Alyssa P. Hacker. Or more generally, I could have something with two variables in it. I could say, the job of x is computer something, and that'll match computer wizard. So there's something here: type will match wizard, or type will match programmer, or x might match various certain things. So there are, in our little example, only three facts in that database that match that query.
Let's see, just to show you some syntax, the same query, this query doesn't match the job of x, doesn't match Lewis Reasoner, the reason for that is when I write something here, what I mean is that this is going to be a list of two symbols, of which the first is the word computer, and the second can be anything. And Lewis's job description here has three symbols, so it doesn't match.
And just to show you a little bit of syntax, the more general thing I might want to type is a thing with a dot here, and this is just standard this notation for saying, this is a list, of which the first element is the word computers, and THE REST, is something that I'll call type. So this one would match. Lewis's job is computer programmer trainee, and type here would be the cdr of this list. It would be the list programmer trainee. And that kind of dot processing is done automatically by the LISP reader.
Well, let's actually try this. The idea is I'm going to type in queries in this language, and answers will come out. Let's look at this. I can go up and say, who works in the computer division? Job of x is computer dot y. Doesn't matter what I call the dummy variables. It says the answers to that, and it's found four answers. Or I can go off and say, tell me about everybody's supervisor.
So I'll put in the query, the primitive query, the supervisor of x is y. There are all the supervisor relationships I know. Or I could go type in, who lives in Cambridge? So I can say, the address of x is Cambridge dot anything. And only one person lives in Cambridge.
OK, so those are primitive queries. And you see what happens to basic interaction with the system is you type in a query, and it types out all possible answers. Or another way to say that: it finds out all the possible values of those variables x and y or t or whatever I've called them, and it types out all ways of taking that query and instantiating it-- remember that from the rule system lecture-- instantiates the query with all possible values for those variables and then types out all of them.
And there are a lot of ways you can arrange a logic language. Prolog, for instance, does something slightly different. Rather than typing back your query, prolog would type out, x equals this and y equals that, or x sequels this and y equals that. And that's a very surface level thing, you can decide what you like. OK.
All right. So the primitives in this language? Only one, right? Primitive query.
OK. Means of combination. Let's look at some compound queries in this language. Here's one. This one says, tell me all the people who work in the computer division. Tell me all the people who work in the computer division together with their supervisors. The way I write that is the query is and. And the job of the x is computer something or other. And job of x is computer dot y. And the supervisor of x is z.
Tell me all the people in the computer division-- that's this-- together with their supervisors. And notice in this query I have three variables-- x, y, and z. And this x is supposed to be the same as that x. So x works in the computer division, and the supervisor of x is z.
Let's try another one. So one means of combination is and.
Who are all the people who make more than $30,000? And the salary of some person p is some amount a. And when I go and look at a, a is greater than $30,000. And LISP value here is a little piece of interface that interfaces the query language to the underlying LISP. And what the LISP value allows you to do is call any LISP predicate inside a query. So here I'm using the LISP predicate greater than, so I say LISP value. This I say and. So all the people whose salary is greater than $30,000.
Or here's a more complicated one. Tell me all the people who work in the computer division who do not have a supervisor who works in the computer division. and x works in the computer division. The job of x is computer dot y. And it's not the case that both x has a supervisor z and the job of z is computer something or other. All right, so again, this x has got to be that x, and this z is going to be that z.
And then you see another means a combination, not. All right, well, let's look at that. It works the same way.
I can go up to the machine and say and the job of the x is computer dot y. And the supervisor of x is z. And I typed that in like a query. And what it types back, what you see are the queries I typed in instantiated by all possible answers. And then you see there are a lot of answers.
All right. So the means of combination in this language-- and this is why it's called a logic language-- are logical operations. Means of combinations are things like AND and NOT and there's one I didn't show you, which is OR. And then I showed you LISP value, which is not logic, of course, but is a little special hack to interface that to LISP so you can get more power. Those are the means of combination.
OK, the means of abstraction. What we'd like to do-- let's go back for second and look at that last slide. We might like to take very complicated thing, the idea that someone works in a division but does not have a supervisor in the division. And as before, name that. Well, if someone works in a division and does not have a supervisor who works in that division, that means that person is a big shot. So let's make a rule that somebody x is a big shot in some department if x works in the department and it's not the case that x has a supervisor who works in the department.
So this is our means of abstraction. This is a rule. And a rule has three parts. The thing that says it's a rule. And then there's the conclusion of the rule. And then there's the body of the rule. And you can read this as a piece of logic which says, if you know that the body of the rule is true, then you can conclude that the conclusion is true. Or in order to deduce that x is a big shot in some department, it's enough to verify that. So that's what rules look like.
Let's go back and look at that merge example that I did before the break. Let's look at how that would look in terms of rules. I'm going to take the logic I put up and just change it into a bunch of rules in this format.
We have a rule. Remember, there was this thing merge-to-form. There is a rule that says, the empty list and y merge to form y. This is the rule conclusion. And notice this particular rule has no body. And in this language, a rule with no body is something that is always true. You can always assume that's true.
And there was another piece of logic that said anything in the empty list merged to form the anything. That's this. A rule y and the empty list merge to form y. Those corresponded to the two end cases in our merge procedure, but now we're talking about logic, not about procedures.
Then we had another rule, which said if you know how shorter things merge, you can put them together. So this says, if you have a list x and y and z, and if you want to deduce that a dot x-- this means constant a onto x, or a list whose first thing is a and whose rest is x-- so if you want to deduce that a dot x and b dot y merge to form b dot c-- that would say you merge these two lists a x and b y and you're going to get something that starts with b-- you can deduce that if you know that it's the case both that a dot x and y merge to form z and a is larger than b.
So when I merge them, b will come first in the list. That's a little translation of the logic rule that I wrote in pseudo-English before.
And then just for completeness, here's the other case. a dot x and b dot y merge to form a dot z if x and b dot y merged to form z and b is larger than a. So that's a little program that I've typed in in this language, and now let's look at it run.
So I typed in the merge rules before, and I could use this like a procedure. I could say merge to form 1 and 3 and 2 and 7. So here I'm using it like the LISP procedure. Now it's going to think about that for a while and apply these rules. So it found an answer. Now it's going to see if there are any other answers but it doesn't know a priori there's only one answer. So it's sitting here checking all possibilities, and it says, no more. Done.
So there I've used those rules like a procedure. Or remember the whole point is that I can ask different kinds of questions. I could say merge to form, let's see, how about 2 and a. Some list of two elements which I know starts with 2, and the other thing I don't know, and x and some other list merge to form a 1, 2, 3 and 4. So now it's going to think about that. It's got to find-- so it found one possibility. It said a could be 3, and x could be the list 1, 4. And now, again, it's got to check because it doesn't a priori know that there aren't any other possibilities going on.
Or like I said, I could say something like merge to form, like, what and what else merge to form 1, 2, 3, 4, 5? Now it's going to think about that. And there are a lot of answers that it might get. And what you see is here you're really paying the price of slowness. And kind of for three reasons. One is that this language is doubly interpreted. Whereas in a real implementation, you would go compile this down to primitive operations. The other reason is that this particular algorithm for merges is doubly recursive. So it's going to take a very long time. And eventually, this is going to go through and find-- find what? Two to the fifth possible answers.
And you see they come out in some fairly arbitrary order, depending on which order it's going to be trying these rules. In fact, what we're going to do when they edit the videotape is speed all this up. Don't you like taking out these weights? And don't you wish you could do that in your demos?
Anyway, it's still grinding there. Anyway, there are 32 possibilities-- we won't wait for it to print out all of them.
OK, so the needs of abstraction in this language are rules. So we take some bunch of things that are put together with logic and we name them. And you can think of that as naming a particular pattern of logic. Or you can think of that as saying, if you want to deduce some conclusion, you can apply those rules of logic. And those are three elements of this language.
Let's break now, and then we'll talk about how it's actually implemented.
STUDENT: Does using LISP value primitive or whatever interfere with your means to go both directions on a query?
PROFESSOR: OK, that's a-- the question is, does using LISP value interfere with the ability to go both directions on the query? We haven't really talked about the implementation yet, but the answer is, yes, it can. In general, as we'll see at the end-- although I really won't to go into details-- it's fairly complicated, especially when you use either not or LISP value-- or actually, if you use anything besides only and, it becomes very complicated to say when these things will work. They won't work quite in all situations. I'll talk about that at the end of the second half today.
But the answer to your question is, yes, by dragging in a lot more power from LISP value, you lose some of the principal power of logic programming. That's a trade-off that you have to make.
OK, let's take a break.