Classes
Classes define an object's behavior and state. Behavior is defined by methods which live in the class. Every object of the same class supports the same methods. State is defined in fields, whose values are stored in each instance.
Methods are declared by name with no function keyword, and instances are created with new.
Defining A Class
Classes are created using the class keyword, unsurprisingly:
class Player {
//
}
This creates a class named Player with no methods or fields.
Methods
To give the class something to do, we give it methods. A method is a name, a parameter list, and a body:
class Player {
spawn() {
console.log("Ready.")
}
}
This defines a spawn method that takes no arguments. To add parameters, put their names inside the parentheses:
class Player {
move(x, y) {
console.log(`Moving to ${x}, ${y}.`)
}
}
Methods take default parameter values and rest parameters just like functions do, and their argument counts work the same way — a required parameter left unbound is an error, extra arguments are dropped. The error names the class and the method:
argument error: `Player.move()` expects at least 2 arguments, got 1
A method body's scope is the class itself, so a method can call a sibling method by bare name:
class Player {
spawn() {
move(0, 0)
}
move(x, y) {
console.log(`Moving to ${x}, ${y}.`)
}
}
Constructors
To create instances of a class, we need a constructor. It is an ordinary method with the reserved name constructor:
class Player {
constructor(name, health) {
console.log(`${name} enters with ${health} health.`)
}
}
Instances are built with the new keyword:
hero = new Player("Artemis", 100)
rival = new Player("Orion", 80)
Note that we didn't call the constructor method directly. new creates the instance first, then invokes the constructor on it. That distinction matters, because inside the constructor body you can already use this, assign fields, and call other methods.
Fields
State lives in fields. Each field has a name, is reached through this, and behaves like a variable.
class Player {
constructor(name, health) {
this.name = name
this.health = health
this.describe()
}
describe() {
console.log(`${this.name}: ${this.health} health`)
}
}
new Player("Artemis", 100) // >> Artemis: 100 health
A field can also be declared directly in the class body, with an initial value:
class Character {
health = 100
name = "unnamed"
}
console.log(new Character().health) // >> 100
These declarations are initializers, not shared class state. They are re-evaluated for every instance — ancestors first, then the class itself — before the constructor runs, so two instances never share a field's value.
Method Scope
Up to this point, "scope" has been used to talk exclusively about variables. Classes introduce a second kind: object scope, which contains the methods available on an object. When you write:
hero.move(3, 4)
you're saying "look up the method move in the scope of the object hero". That's what . does, and the object to the left of the period is the object you want to look up the method on.
this
Things get more interesting when you're inside the body of a method. When the method is called on some object and the body is being executed, you often need to access that object itself. You can do that using this.
class Player {
setHealth(health) {
this.health = health
}
restore() {
this.setHealth(100)
console.log(this.health)
}
}
The this keyword works sort of like a variable, but has special behavior. It always refers to the instance whose method is currently being executed. This lets you invoke methods on "yourself".
It's an error to refer to this outside of a method.
this keeps meaning the same instance inside a nested block, so a loop or a branch does not change what it refers to:
class Player {
setHealth(health) {
this.health = health
}
countdown() {
this.setHealth(3)
for (i in 1 .. 3) {
console.log(this.health)
}
}
}
A callback created inside a method keeps hold of the instance it was created in, so this still means what it meant where the callback was written.
(In technical terms, a function's closure includes this. Ghost can do this because it makes a distinction between methods and functions.)
Inheritance
A class can inherit from a "parent" or superclass. When you invoke a method on an object of some class, if it can't be found, it walks up the chain of superclasses looking for it there.
To inherit another class, use extends when you declare your class:
class Enemy extends Character {
//
}
This declares a new class Enemy that inherits from Character — every method and field Character defines is available on an Enemy without repeating it.
super
super reaches the version of a member defined by the superclass of the class the running method was declared in. It is how an overriding method calls the one it overrode, and how a subclass constructor runs its parent's:
class Character {
constructor(name) {
this.name = name
}
describe() {
return this.name
}
}
class Enemy extends Character {
constructor(name) {
super.constructor(name)
}
describe() {
return super.describe() + " (hostile)"
}
}
console.log(new Enemy("Grendel").describe()) // >> Grendel (hostile)
A subclass constructor that doesn't call super.constructor() simply doesn't run the parent's — field declarations from ancestors are still applied either way.
Traits
In addition to class inheritance, Ghost supports traits. Traits are like classes, but they can't be instantiated. Instead, they're used to share methods and fields between classes.
Inheritance is for what something is; a trait is for what something can do. Taking damage is not the sort of thing that belongs to one branch of a hierarchy — a player, an enemy, and a destructible crate all do it, without being the same kind of thing.
trait Damageable {
hurt(amount) {
this.health = this.health - amount
}
}
This defines a trait named Damageable with a hurt method. To use it, you use the use keyword inside the class body:
class Player {
use Damageable
health = 100
}
It's as if you had written:
class Player {
health = 100
hurt(amount) {
this.health = this.health - amount
}
}
You can use multiple traits by separating them with commas:
class Player {
use Damageable, Healable
health = 100
}
A trait's methods can call methods the using class provides, which is the usual way to build one. The trait supplies the behavior and leaves a hole for the class to fill:
trait Shouts {
taunt() {
return this.speak().toUpperCase()
}
}
class Ogre {
use Shouts
speak() {
return "grrr"
}
}
console.log(new Ogre().taunt()) // >> GRRR
Shouts knows nothing about ogres. Any class that can speak() can use it.
Comparing Instances
Instances compare by identity. Two instances of the same class holding equal fields are still two different objects, so == between them is false unless they are literally the same object.
class Player {
constructor(name) {
this.name = name
}
}
a = new Player("Artemis")
b = new Player("Artemis")
console.log(a == b) // >> false
console.log(a == a) // >> true
This is the one place classes differ from lists and maps, which compare by their contents. To compare two instances by what they hold, compare the fields you care about.
Classes From Modules
A class can come from somewhere other than the file you are in. A module exports its top-level classes like any other name, and a Go program embedding Ghost can register one whose instances it builds itself. Either way, new works identically:
import { Image } from "lumen:image"
sprite = new Image('player.png')
The class name in a new may also be dotted, and calls chain straight off the result:
import "helpers" as m
point = new m.Point(1, 2).add(3).toString()
What Ghost's Classes Don't Have
Deliberately small, and unlikely to grow:
- No static members, and no way to attach state to the class rather than an instance.
- No access modifiers. Every field and method is public.
- No
interfaceorabstract. Traits cover what those are usually reached for. - No getter/setter syntax. A field is read and written directly; a method is a method.
- Single inheritance only. One
extends, plus as many traits as you like.
Two mistakes get their own message rather than failing obscurely. ClassName.new() is a syntax error pointing at new ClassName(), and declaring a field called constructor is a syntax error telling you to write it as a method.