Properties

Dear Computer

Chapter 5: Objects

Properties

To build software that is safe and maintainable, state should be private. No entity outside of an object should be able to modify the state except through carefully designed public behaviors. If state is public, then clients may modify it in an uncontrolled manner and leave the object in an inconsistent state. Consider this JavaScript program that demonstrates how a Vector2 may be made inconsistent:

JavaScript
class Vector2 {
  constructor(x, y) {
    this.x = x;
    this.y = y;
    this.magnitude = Math.sqrt(x * x + y * y);
  }
}

const offset = new Vector2(3, 4);
console.log(offset.magnitude);      // prints 5 correctly
offset.x = 0;
console.log(offset.magnitude);      // prints 5 incorrectly

Prior to 2022, JavaScript had no notion of private state, so x could be freely modified by clients. But the magnitude would no longer correct after the modification.

To keep an object consistent, all state must be private, and any clients that need to access it must use getters and setters. The setters offer a hook for the class designer to update any associated or derived state. This revised JavaScript program updates magnitude whenever x is set:

JavaScript
class Vector2 {
  constructor(x, y) {
    this.x = x;
    this.y = y;
    this.synchronizeMagnitude();
  }

  getX() {
    return this.x;
  }

  setX(value) {
    this.x = value;
    this.synchronizeMagnitude();
  }

  synchronizeMagnitude();
    this.magnitude = Math.sqrt(this.x * this.x + this.y * this.y);
  }
}

const offset = new Vector2(3, 4);
console.log(offset.magnitude);      // prints 5 correctly
offset.setX(0);
console.log(offset.magnitude);      // prints 4 correctly

Channeling access through getters and setters increases the safety of the code and the flexibility of a class's implementation. However, it also makes the client code noiser with a proliferation of method calls. Some languages provide a middle ground that offers safety, flexibility, and low noise through properties. Properties are virtual state. They look like state, but they are really behaviors. They provide the syntactic illusion of direct access to state, but the references and assignments to a property are really calls to getters and setters.

In JavaScript, virtual state is added to an object using special get and set methods. The get x() method is called wherever an rvalue expression like offset.x appears in the code. The set x(value) method is called wherever an lvalue assignment like offset.x = value appears in the code. The setter method gives the opportunity for the magnitude to be updated even though offset.x = 0 looks like a plain old assignment statement to just the x variable:

JavaScript
class Vector2 {
  constructor(x, y) {
    this._x = x;
    this._y = y;
    this.synchronizeMagnitude();
  }

  get x() {
    // return this.x;    <- infinite recursion!
    return this._x;
  }

  set x(value) {
    this._x = value;
    this.synchronizeMagnitude();
  }

  synchronizeMagnitude();
    this.magnitude = Math.sqrt(this._x * this._x + this._y * this._y);
  }
}

const offset = new Vector2(3, 4);
console.log(offset.magnitude);      // prints 5 correctly
offset.x = 0;
console.log(offset.magnitude);      // prints 4 correctly

The instance variables and properties cannot have the same name. Otherwise the getter and setter would recursively call themselves and overflow the stack. Here, the instance variable names have been prefixed with _ to hint that they are private state.

The syntax for properties varies wildly across languages. This Kotlin class has a property for the magnitude:

Kotlin
import kotlin.math.sqrt

class Vector2(var x: Double, var y: Double) {
  val magnitude: Double
    get() = sqrt(x * x + y * y)
}

fun main() {
  val p = Vector2(3.0, 4.0)
  println(p.magnitude)        // prints 5
  p.x = 0.0
  println(p.magnitude)        // prints 4
}

The magnitude is purely a behavior. There is no backing field. This is perhaps a better design than the JavaScript implementation since there's no derived and cached state that needs to be updated when x and y change. A setter for the magnitude could be added. It wouldn't set the magnitude, since there isn't state for it. Rather, it would scale x and y to reach the desired magnitude.

An object's state in Ruby is always private. Unlike Java and C++, not even other instances of the same class may access it. Instead, you expose the state through getters and setters:

Ruby
class Vector2
  def initialize(x, y)
    @x = x
    @y = y
  end

  def x
    @x
  end

  def x=(value)
    @x = value
  end

  # ...
end

Adding getters and setters for instance variables is tedious, and Ruby therefore offers the attr_accessor helper method to generate them automatically. You call this method from just inside the class, passing the names of the instance variables as symbols:

Ruby
class Vector2
  attr_accessor :x, :y

  def initialize(x, y)
    @x = x
    @y = y
  end
end

The attr_reader method generates just a getter, the attr_writer just a setter, and attr_accessor both a getter and a setter.

These generators only work when there's a real backing field to access. Adding a getter for a virtual magnitude is no different than adding a regular method:

Ruby
class Vector2
  attr_accessor :x, :y

  def initialize(x, y)
    @x = x
    @y = y
  end

  def magnitude
    Math.sqrt(x * x + y * y)
  end
end

offset = Vector2.new(3, 4)
puts offset.magnitude        # prints 5
offset.x = 0
puts offset.magnitude        # prints 4
← InstantiationSubclassing →