Closuretalk
Paradigm(s) | Object-oriented |
---|---|
Designed by | User:rdococ |
Appeared in | 2022 |
Computational class | Turing complete |
Major implementations | Original |
File extension(s) | .tiny |
- Not to be confused with McCall and Tesler's TinyTalk language developed in 1980.
Closuretalk (formerly known as tinytalk) is a minimalistic, purely object-oriented programming language by User:Rdococ. It was designed to answer the question of "what is the most object-oriented language possible?".
Semantics
Every value is an object. An object consists of a set of methods responding to various named messages and/or decorated objects whose methods are copied by the new object. Variables are created by definitions or as parameters in method signatures.
Numbers, strings, booleans and nil are provided as primitive objects. The builtin Cell
and Array
objects can be asked to create new
mutable cells, and the console
object can print:
, write:
, read
a line of input or error:
.
Syntax
Closuretalk has a simplified form of Smalltalk syntax with an extra form added for object literals.
Statements & Variables
Expressions can be chained with .
, typically returning the result of the last expression in the chain. A variable is defined with the :=
operator and can be assigned to with the <-
operator. Variable definitions are hoisted but only assigned to when the definition is reached.
Object creation
Objects are created with []
, and contain methods and decorations separated by |
. A method is a method signature, such as attackedWith: weapon By: entity
, followed by an expression. The syntax for decoration is ...
followed by an expression.
Method syntax
Methods are called by specifying the receiver followed by a message and arguments.
- Unary messages are single words. For example,
'fraction' import
imports the 'fraction' file, andx abs
takes the absolute value of x. - Binary operators are symbol characters. e.g.
2 + 3
and9 / 2
. - Keyword messages consist of a set of words. Each word ends in
:
and leads into an argument. For example,Point atX: x Y: y
. The initial word beings with a lowercase letter, and non-initial words begin with uppercase letters.
Unary messages have the highest precedence, followed by binary operators, and then keyword messages. Binary operators are left-associative and do not follow the order of operations. Keyword messages are also left-associative. The latter two rules are subject to change.
Blocks & explicit returns
Blocks are created with {}
. This produces an object with a single method called do
with special behaviour. An explicit return is constructed with ^
and an unchained expression, e.g. ^ x * 2
. Explicit returns ignore block methods, making them useful for control flow constructs.
Examples
brainfuck
'switch' import. brainfuck := [tokenizer: code ip := 0. self := [read ip <- ip + 1. char := code at: ip. switch on: char case: [when '+' | do [if: c c inc]] case: [when '-' | do [if: c c dec]] case: [when '<' | do [if: c c left]] case: [when '>' | do [if: c c right]] case: [when '[' | do [if: c c loop]] case: [when ']' | do [if: c c end]] case: [when ',' | do [if: c c input]] case: [when '.' | do [if: c c output]] case: [when '' | do [if: c c end]] else: [do self read]]. |compile: code tokenizer := brainfuck tokenizer: code. self := [continue tokenizer read if: [inc self compose: [instruct: m m inc] With: self continue. |dec self compose: [instruct: m m dec] With: self continue. |left self compose: [instruct: m m left] With: self continue. |right self compose: [instruct: m m right] With: self continue. |loop body := self continue. self compose: [instruct: m m loop: body] With: self continue. |end [instruct: m] |input self compose: [instruct: m m input] With: self continue. |output self compose: [instruct: m m output] With: self continue] |compose: a With: b [instruct: m a instruct: m. b instruct: m]]. program := self compose: self continue With: [instruct: m m end]. |interpreter tape := Array make. tape at: 1 Put: 0. tp := 1. len := 1. self := [inc tape at: tp Put: (tape at: tp) + 1 % 256. |dec tape at: tp Put: (tape at: tp) - 1 % 256. |left tp <- tp - 1. tp < 1 if: [true console error: 'Tape pointer exceeded left edge' | false]. |right tp <- tp + 1. tp > len if: [true len <- len + 1. tape at: tp Put: 0. |false]. |loop: body (tape at: tp) > 0 if: [true body instruct: self. self loop: body. |false] |input tape at: tp Put: (console read: 1) byte. |output console write: (tape at: tp) character. |end tape] |luaBackend rawCode := 'tape, tp = {0}, 1;'. code := [append: piece rawCode <- rawCode, piece | value rawCode]. self := [inc code append: 'tape[tp] = (tape[tp] + 1) % 256;' |dec code append: 'tape[tp] = (tape[tp] - 1) % 256;' |left code append: 'tp = tp - 1;' |right code append: 'tp = tp + 1;tape[tp] = tape[tp] or 0;' |loop: body code append: 'while tape[tp] > 0 do '. body instruct: self. code append: ' end;' |input code append: 'tape[tp] = io.read(1):byte();' |output code append: 'io.write(string.char(tape[tp]));' |end code value]].
This is an extensible brainfuck compiler. You use brainfuck compile:
to compile code into a program object. The program object can be told to instruct:
a machine object on what to do with the [inc | dec | left | right | loop: body | input | output | end]
interface. This machine can interpret the program, as with brainfuck interpreter
, compile it into another language like brainfuck luaBackend
, or do basically anything else with it.
Shapes
This is an implementation of shapes as objects. Every shape is expected to return its top left and bottom right points with origin
and corner
, and return if it contains a specific point with contains:
. This can be used to represent all kinds of possible shapes, including squares, circles, and arbitrary combinations of shapes as demonstrated below.
'Vector' import. Square := [at: origin WithSize: size corner := origin + (Vector makeX: size Y: size). [contains: point point >= origin and: point < corner |origin origin |corner corner]]. Circle := [at: centre WithRadius: radius [contains: point (point - centre) size < radius |origin Vector makeX: centre x - radius Y: centre y - radius |corner Vector makeX: centre x + radius Y: centre y + radius]]. Union := [of: shapeA And: shapeB [contains: point shapeA contains: point or: (shapeB contains: point) |origin shapeA origin origin: shapeB origin |corner shapeA corner corner: shapeB corner]]. Intersection := [of: shapeA And: shapeB [contains: point shapeA contains: point and: (shapeB contains: point) |origin shapeA origin corner: shapeB origin |corner shapeA corner origin: shapeB corner]].
The information provided by the [contains: | origin | corner]
interface is enough to draw the shape to the console. Here is a Drawer
implementing just that.
Drawer := [draw: shape yLoop := [on: y xLoop := [on: x console write: (shape contains: (Point atX: x Y: y) if: [true '#' | false ' ']). x < shape corner x ifTrue: [do xLoop on: x + 1]]. xLoop on: shape origin x. console print: ''. y < shape corner y ifTrue: [do yLoop on: y + 1]]. yLoop on: shape origin y].
You could create other drawer objects that support draw:
if you wanted to support more graphical forms of display. Shapes could return the colour they have at a specific point rather than a boolean, enabling them to support multiple colours. At that point, though, you would have a more general 'image' interface supporting both bitmap and vector graphics at arbitrary resolutions, or graphics stored remotely on an Internet server and cached by the image itself.
Sets
A set can be defined in an object-oriented way. The interface for sets as implemented below is [empty | contains:]
. empty
returns true or false depending on if the set is empty, and contains:
returns true if the set contains a value. You can define infinite sets, such as the set of all natural numbers or odd numbers. You can define the set of all sets that do not contain themselves, but it can result non-termination.
nats := [empty false |contains: n n > 0 and: n floor = n]. odds := [empty false |contains: n nats contains: n and: n % 2 = 1]. console print: (odds contains: 1). "true" console print: (odds contains: 2). "false" console print: (odds contains: 3). "true" console print: (odds contains: 4). "false" console print: (odds contains: 5). "true" russell := [empty false |contains: set (set contains: set) not]. console print: (russell contains: russell). "infinite loop!"
You could define a Set
object that can make:
useful sets by decorating naked set objects with useful operations like filter:
.
Lists
While Closuretalk is purely object-oriented, you can define data structures by simply implementing objects with plain getters, setters and/or visitors. Here's an example of a list data type.
List := [with: first Before: rest self := [if: cases cases with: first Before: rest |map: fn List with: (fn of: first) Before: (rest map: fn) |fold: fn Onto: id fn of: first And: (rest fold: fn Onto: id) |makeString 'List(', first makeString, rest makeRestString |makeRestString ', ', first makeString, rest makeRestString] |empty self := [if: cases cases empty |map: fn List empty |fold: fn Onto: id id |makeString 'List()' |makeRestString ')']]. ls := (List with: 1 Before: (List with: 2 Before: (List with: 3 Before: List empty))). mls := ls map: [of: x x * 2]. fls := ls fold: [of: x And: y x * y] Onto: 1. console print: ls. "List(1, 2, 3)" console print: mls. "List(2, 4, 6)" console print: fls. "6"
The List
constructors ensure that all lists are finite, but you can always feed in custom implementations of infinite lists to objects expecting finite lists to cause infinite loops. All data types are essentially bullied into laziness.
Vectors
Vector := [makeX: x Y: y a := [+ b Vector makeX: x + b x Y: y + b y |- b Vector makeX: x - b x Y: y - b y |size (x * x + (y * y)) sqrt |makeString 'Vector(', x, ', ', y, ')' |x x |y y |origin: b Vector makeX: (x smaller: b x) Y: (y smaller: b y) |corner: b Vector makeX: (x larger: b x) Y: (y larger: b y) |< b a x < b x and: a y < b y |= b a x = b x and: a y = b y |> b b < a |<= b a x <= b x and: a y <= b y |>= b b <= a]].
Switch construct
This is very useful when you want to dispatch on the value of builtin "data" objects like numbers and strings.
switch := [case: case case if if: [true self := [case: c self | else: c case do] |false switch] |else: case case do |on: value decorator := [for: s [case: c decorator for: (s case: [if c if = value | ...c]) | ...s]]. decorator for: switch]. x := console read makeNumber. switch case: [if x = 1 | do 'x is 1!'] case: [if x = 2 | do 'x is 2!'] else: [do 'x is not 1 or 2!'].
FizzBuzz
This sophisticated, object-oriented FizzBuzz supports arbitrary fizzbuzzes with any number of keywords.
FizzBuzz := [new rules := Array new. ruleCount := 0. self := [for: n result := ''. loop := [on: i rule := rules at: i. n % rule divisor = 0 ifTrue: [do result <- result, rule string]. i < ruleCount if: [true loop on: i + 1 | false]]. loop on: 1. result = '' if: [true n asString | false result]. | add: rule ruleCount <- ruleCount + 1. rules at: ruleCount Put: rule] | default fb := FizzBuzz new. fb add: [divisor 3 | string 'Fizz']. fb add: [divisor 5 | string 'Buzz']. fb. | test fb := FizzBuzz default. loop := [on: n console print: (fb for: n). n < 100 ifTrue: [do loop on: n + 1]]. loop on: 1. console print: 'Test done!'].
Implementations
There is a reference implementation written in & transpiling to Lua. It includes a REPL, from which you can 'xxx' import
to load the included examples and play around with them. Additional examples include a custom Fraction number type.
Conclusion
An interesting thought experiment. Whether this is "the most object-oriented language possible" depends on your definition of OOP. The best definition I have found relies on a duality:
In non-OO programming, you program with plain data types, and include first-class functions only where (interface) polymorphism is required. In OO programming, you program with first-class functions, and make use of delegation only where monomorphism is required.
I think Closuretalk is a purely object-oriented language. However, two weird implications of this OOP definition are:
- 'True' inheritance is more object-oriented than simple proxying. I decided not to implement inheritance because I believe it to be less modular.
- CLOS -- an object system supporting multiple dynamic dispatch -- is more object-oriented because it encourages more polymorphism. This strikes me as wrong for the same reason: reduced modularity.
So, it seems that my definition is incomplete. It's missing a notion of modularity separate to polymorphism and delegation, which Closuretalk implements better than other OO languages. (Also, this is starting to sound a lot like EIP.)
As for the language itself, further simplifications can be made. There is no unification between scopes/variables and objects/fields as there was in Self, for example.