Demonstrating the Value of Objects in Live Prolog

A Toy Example To Demonstrate the Value of Objects in Live Prolog

Core Thesis: Prolog already behaves like a protocol-based object system; making live, dynamic assertions expressive precipitates a metaobject protocol, and implies the possibility of a next generation operating system that merges the relational and object styles.

Suppose I’m a programmer in 1998 and writing an application for a pet store. I sell dogs. I want to use PROLOG, the fifth generation language of the future ™, to store internal information about pets.Let’s use a principled relational data model to store information about pets. Remember that in a relational database, IDs should always really be meaningless in a relational database to avoid conflation of concerns (PS I recently realised this principle does not apply to merkle trees, out of interest).

dog(dog_1).
name(dog_1, fido).
colour(dog_1, white).

speak(DogID) :- 
  dog(DogID),
  name(DogID, Name),
  format("Woof! My name is ~w", [Name]).

Now in order to add new predicates to our store (this is a live petstore because our pets are alive and we don’t sell dead pets, so it requires a live system), we would normally have to compile in new files. Alternatively, we could make these predicates dynamic, and add them at runtime via the REPL or use the HTTP mail service.

make_dog(Name, Colour, ID) :-
    gensym(dog_, ID),
    asserta(dog(ID)),
    asserta(name(ID, Name)),
    asserta(colour(ID, Colour)).    
?- make_dog(fido, white, DogID), name(DogID, Name).
DogID = dog_1.
Name = fido.

Hooray! It works just fine. I can now even save the state of my program using qsave_program/2. What could our model now look like?

make_turtle(Name, FavouriteFood, ID) :-
    gensym(turtle_, ID),
    asserta(turtle(ID)),
    asserta(name(ID, Name)),
    asserta(favourite_food(ID, FavouriteFood)).

I would have to assert this predicate in order to actually use it. Moreover, there’s some repeated concepts between make_dog and make_turtle. For example, the gensym, and the assertion of a predicate that simply states something instantiates the behaviour of a ‘type’. Maybe we should abstract this concept.

make_animal(Type, Slots, ID) :-
    gensym(animal_, ID),
    asserta(type(ID, Type)),
    make_slots(Slots, ID).
    
make_slots([], _).
make_slots([Slot|Slots], ID) :-
    asserta(Slot),
    make_slots(Slots, ID).    
    
speak(DogID) :- 
  type(DogID, dog),
  name(DogID, Name),
  format("Woof! My name is ~w", [Name]). 
?- make_animal(turtle, [name(ID, turt), favourite_food(ID, cucumber)], ID).
ID = animal_1.

Note that we no longer really even need a concept of ‘animal’ here- what we really have is a more general data abstraction- an ‘object’.
Also note that we have to do a type-check or pass the type every time we want to call the predicate associated with the type- the ‘method’.

What we could do is make slots into Head-Body clauses, like regular PROLOG predicates! Then, we wouldn’t even necessarily need a ‘ML’ style type variant, the problems of which are well-studied (See: Data Abstractions Revisited https://www.cs.utexas.edu/~wcook/Drafts/2009/essay.pdf) unless the user so desired.

make_obj(Slots, ID) :-
    gensym(obj_, ID),
    make_slots(Slots, ID).

make_slots([], _).
make_slots([(SlotHead, SlotBody)|Slots], ID) :-
    asserta(SlotHead :- SlotBody),
    make_slots(Slots, ID).

This works nicely- but suppose we want to perform inheritance. Say that, in our original pet store, we discover a new animal- let’s call it a xog. it is similar to the dog_1 we have in many ways- but we need to give it a new name to reflect its existence! Well, we could add an extra slot to ‘dog’ and treat it as a kind of dog, but maybe it would be best to use inheritance to solve this problem. Well, we could try to make an object with a special inheritance slot, and make_obj could go and directly add the methods from the inherited object during initialisation that weren’t defined by the user. But instead, why not simply call the slots that were defined by that object? For this, we could wrap predicate calls in a new predicate call_slot that does this logic for us.

call_slot(SlotHead) :-
    SlotHead =.. [F, ID|Args],
    (SlotHead
    ;
     inherits(ID, InheritsID),
     InheritsHead =.. [F, InheritsID|Args], 
     call_slot(InheritsHead)).

Let’s give it a whirl.

?- make_obj([(name(ID, "Xido"), true), (inherits(ID, dog_1), true)], ID), call_slot(speak(ID)).
Woof! My name is Fido!
ID = dog_1

Oh, the name is incorrect. This is because the method refers to the inherited ID, but has no reference to the original object to look up its name. We therefore have to have a concept of ‘Self’ so that delegation can occur.

We need a new base fact that we can store this info. A first-order data structure for our program. Let’s call this the slot, which takes an ID, a slot head which takes Self as its first argument, and a body. Now let’s re-define objects around those slots.

call_slot(SlotHead) :-
    SlotHead =.. [_, ID|_],
    call_slot(ID, SlotHead).

call_slot(CallerID, SlotHead) :-
    (slot(CallerID, SlotHead, Body),  M:call(Body)
    ; 
    slot(CallerID, inherits(CallerID, InheritsID), InheritsBody),
    M:call(InheritsBody)
    call_slot(InheritsID, SlotHead)).


make_obj(Slots, ID) :-
    gensym(obj_ID),
    make_slots(ID, Slots).

(...)
?- make_obj([(name(Self, "Xido"), true), (inherits(Self, dog_1), true)], ID), call_slot(speak(ID)).
Woof! My name is Xido!

Yay! Now we can even define overrideable metaobjects where basic operations are overrideable and eventually we end up with something like


%% This is needed to preserve caller module. Otherwise, within the scope of defining a slot body, you are in this module, rather than your caller, meaning you'd have to namespace all of your calls when defining slots.
:- meta_predicate(call_slot(:)).
:- meta_predicate(call_slot(?, :)).

%% call_slot is a dispatch shell, in the future we can flesh it out and provide more hooks, as well as allowing new metaobjects to define their own call_slot capabilities.
call_slot(SlotHead) :-
    strip_module(SlotHead, _, SlotHead0),
    SlotHead0 =.. [_, ID|_],
    call_slot(ID, SlotHead).

call_slot(CallerID, SlotHead) :-
    slot(CallerID, metaobject(CallerID, MetaID), Body),
    Body,
    slot(MetaID, call_slot(MetaID, CallerID, SlotHead), CallSlotBody),
    CallSlotBody.

make_obj(Slots, ID) :-
    make_obj(root, Slots, ID).

make_obj(Meta, Slots, ID) :-
    call_slot(make_obj(Meta, Slots, ID)).

%% The only starting object is root, a metaobject which determines how new objects are created.
slot(root, name(_Self, root), true).

slot(root, metaobject(_Self, root), true).

slot(root, call_slot(_Self, CallerID, SlotHead),
     (
         strip_module(SlotHead, M, SlotHead0),
         (slot(CallerID, SlotHead0, Body)
         -> M:call(Body)
         ; (slot(CallerID, inherits(CallerID, InheritsID), Body),
            M:call(Body),
            call_slot(InheritsID, SlotHead)))
     )).

slot(root, make_obj(Self, Slots, ID),
     (
         gensym(obj_, ID),
         asserta(slot(ID, metaobject(_, Self), true)),
         call_slot(make_slots(Self, ID, Slots))
     )).

slot(root, make_slots(_Self, _, []), true).
slot(root, make_slots(Self, ID, [(SlotHead, SlotBody)|Slots]),
     (
         asserta(slot(ID, SlotHead, SlotBody)),
         call_slot(make_slots(Self, ID, Slots))
     )).

Here’s an example of a simple metaobject taken straight from the Art of the Metaobject Protocol:

%% A simple counter metaobject
simple_metaobject_init(MetaObjectID, Slots) :-
    make_obj([(make_obj(Self, Slots, ObjID),
               (make_obj(Slots, ObjID),
                call_slot(counter(Self, N)),
                N1 is N + 1,
                call_slot(make_slots(Self,
                                     Self,
                                     [(counter(_S, N1), true)])))),
              (inherits(Self, root), true),
              (counter(Self, 0), true)],
             MetaObjectID),
    findall(Head-Body, slot(MetaObjectID, Head, Body), Slots).

%% Metaobject increases its counter slot whenever it creates a new object, otherwise inheriting from the root object
simple_metaobject_make_object(MetaObjectID, NewObj, OldCounter, NewCounter) :-
    call_slot(counter(MetaObjectID, OldCounter)),
    call_slot(make_obj(MetaObjectID, [], NewObj)),
    call_slot(counter(MetaObjectID, NewCounter)).
?- simple_metaobject_init(MetaID, Slots), simple_metaobject_make_object(MetaID, NewObj, OldCounter, NewCounter).
OldCounter = 0,
NewCounter = 1.

Something cool is that since counter is never retracted here, we don’t lose access to historical states. One could imagine a ‘bitemporal’ metaobject and ‘transactional’ metaobject that allow us much safer assertions along with these ‘dirty’ asserts, as well as a cohesive approach to storing historical data.

Note that having to call_slot every time I want to use this system is annoying, but this can be seen as an interpreter for a future language where everything would be a relational object (and thus everything would be a slot). The allocation of objects in such a system is a very interesting topic, and I hope to write about this more in a future post. We can also use make_obj as a directive in order to define objects on the top-level (macros could also be used for this, and are probably a better option to be honest).

There are some very interesting properties that arise from this system. For example, if we never retract, we can build up facts when we make new slots and backtrack over them. We can also write slots in a way that allows us bidirectional evaluation.

backtracking_object_init(ObjID) :-
    make_obj([(val(_Self, X),
               member(X, [1, 2, 3]))],
             ObjID).
?- backtracking_object_init(_ObjID), call_slot(val(_ObjID, X)).
X = 1;
X = 2;
X = 3.

But the particular, overrideable properties of the system in general aside, I hope to have shown that a meta-object protocol precipitates quite naturally from attempts to make Prolog dynamic assertions pleasant to deal with.

  • We need runtime instances → we need dynamic predicates
  • We need inheritance → we need slots
  • We need extensible semantics → we need a metaobject protocol.

We should be able to build many lovely things on top to support a live PROLOG-based operating system, such as garbage collection, properly relational assertion semantics (transactions) that automatically roll back on backtracking, etc etc. If my core thesis about this is incorrect, it’s very important that I figure this out now because it guides a lot of my research. I expect to use this model as a guidance for implementing our objects in Elixir.

However, one really critical thing that is still lacking within this model is thoughtful consideration of distributed logic.
I have been generally taking inspiration from systems like XSB Prolog while thinking about this XSB Prolog | XSB, due to the well-founded semantics and ability to think of its tables as CRDTs (Three Popular Resolution Strategies). I would really like to get help and critique from Jamie, both for the general ideas laid out in this post, as well as the properties that would come from attempting to make this system monotonic. My intuition tells me that we can steal some ideas from XSB, resulting in a sort of live event-brokering system where everything would be an object.

1 Like