home > writings > A fresh look at OOP with concurrent objects
2009 May 15
Let me present a concurrent, message passing programming framework implemented as part of the Spark-Scheme project. (By the way, Spark is my own Scheme programming environment). I was profoundly influenced by Alan Kay's short and clear definition of OOP:
OOP to me means only messaging, local retention and protection and hiding of state-process, and extreme late-binding of all things.
Anyone who is familiar with closures know how to create objects that can "retain, protect and hide state" and respond to "messages".
Here is an object that models a Bank Account. It can respond to three messages: deposit, withdraw and balance?. Messages are encoded as patterns. We use the match library to decode the messages.
[Note: The samples will work only in Spark]
(import (match)) (define (bank-account) (let ((balance 0)) (lambda (message) (match message (('deposit amount) (set! balance (+ balance amount)) balance) (('withdraw amount) (if (< amount balance) (set! balance (- balance amount))) balance) ('balance? balance) (_ "Not a valid message.")))))
The bank-account object definition (or class definition in Java or C++ parlance) satisfy all the properties as specified by Dr. Kay. It can receive and send messages. Its state is locally retained, hidden and protected. It does not contain statically bound type information. In other words, you can send the message deposit to any object that can respond to that message. It need not be an instance of bank-account or one of its sub-types.
The following code shows how to instantiate a bank-account and send messages to it:
(define account (bank-account)) (account (list 'deposit 1000)) ;; => 1000 (printf "Balance: ~a~n" (account 'balance?)) ;; => 1000 (account (list 'withdraw 100)) ;; => 900 (printf "Balance: ~a~n" (account 'balance?)) ;; => 900
Joe Armstrong's "Programming Erlang - Software for a Concurrent World" gave me some more ideas about the real capabilities that an OOP language should posses. They can be summarized as:
I decided to implement such an object system in Spark, which will have all the above listed properties. This framework is heavily influenced by what I saw in Erlang. The basic idea is to spawn a tiny process for each object. These processes are not operating system threads and does not have any constraints imposed by the host system. That means, a Spark program can be made out of hundreds of thousands of concurrent objects.
Let us re-implement the bank-account object to support concurrency:
(import (match)) (define (bank-account pid) (let loop ((balance 0) (message (receive pid))) (match message ((from-pid 'deposit amount) (set! balance (+ balance amount)) (send from-pid balance)) ((from-pid 'withdraw amount) (if (< amount balance) (set! balance (- balance amount))) (send from-pid balance)) ((from-pid 'balance?) (send from-pid balance)) (_ (send from-pid "Not a valid message."))) (loop balance (receive pid))))
The procedures send and receive are used to exchange messages between objects. A short description of the code follows:
The bank-account object receives incoming messages in a loop. pid is a number that uniquely identifies an object within the system. This is passed as the argument of receive to retrieve messages from the object's mailbox. When a message arrives in the mailbox, it is decoded, an appropriate computation is executed and the result is send back to the requesting object. The sender object (or client) is identified by from-pid which is included as the first element of all messages.
The following sample shows one way to code a client object for the bank-account:
(define client-pid "banking-client") (define (banking-client) (lambda (bank-account-pid message-to-send) (let ((new-pid (spawn (lambda (pid) (register pid client-pid) (printf "~a: ~a~n" (car message-to-send) (receive pid)) (flush-output))))) (send bank-account-pid (cons client-pid message-to-send)))))
Here we introduced a new procedure called spawn. It is used to create a new concurrent object. This object is represented by a closure which takes one argument - the new process id. The client object is wrapped into a procedure that takes the bank-account pid and a message as its arguments. We can repeatedly call this procedure, which internally uses a singleton to send messages to bank-account and receive and display the results.
The following code shows how to use the bank-account and the banking-client objects to create a simple banking system:
;; Start the bank account server. (define account (spawn bank-account)) ;; Map the object or process id with a name for ease of identification. (define bank-account-server "bank-account-server") (register account bank-account-server) ;; Do banking. ((banking-client) bank-account-server (list 'deposit 1000)) ;; => 1000 ((banking-client) bank-account-server (list 'withdraw 100)) ;; => 900 ((banking-client) bank-account-server (list 'balance?)) ;; => 900
The register procedure is used to map a name to an object id. Both send and receive can make use of this name as well as the integer id. The bank-account object can serve any number of clients without blocking other computations in the system because it lives in its own parallel world.
Now to the most exciting part! We have developed and tested our banking software and now we want to deploy it in a distributed environment. This could be done for various reasons: we may like to run computationally demanding tasks on dedicated hardware or we would like geographically separated clients to make use of our software as a service etc etc.
Let us see how we can deploy the bank-account object on a different machine, so that it can act as a real network server. To achieve this we need not make any changes to the bank-account object itself! The only thing we need to do is start the built-in remoting service, which will enable the object to receive messages across the network:
;; Start the remoting service on the default port 1383. (remoting!) ;; Create a ''bank-account'' object as usual. ;; Magic! It can now respond to both local and remote messages! (define account (spawn bank-account)) (define bank-account-server "bank-account-server") (register account bank-account-server)
To enable the banking-client to send messages to the remote bank-account, we need to make a few simple modifications. First, we change the call to send, where we append the local host name to the client identifier so that the server can use it to send back the results. The object name will now look like an email address:
;; Change this line in the banking-client procedure. (send bank-account-pid (cons (string-append client-pid "@myhost") message-to-send))
The remote object identifier should also contain the network name of the computer on which it's running. To get back messages from the remote object, we should start the remoting service locally:
(remoting!) (define bank-account-server "bank-account-server@remotehost") ;; new ((banking-client) bank-account-server (list 'deposit 1000)) ;; => 1000 ((banking-client) bank-account-server (list 'withdraw 100)) ;; => 900 ((banking-client) bank-account-server (list 'balance?)) ;; => 900
That's it! Our OOP framework is able to simulate objects as they exist in the real world. They have local, private state. They can receive and send messages despite being in different geographical locations. What do you think? Ain't this Object Oriented Programming the way it's meant to be?
Links and References:
Comments: