Closuretalk

From Esolang
Jump to navigation Jump to search
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, and x abs takes the absolute value of x.
  • Binary operators are symbol characters. e.g. 2 + 3 and 9 / 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.