OOP Isn't Obsessed Enough About Time Control

In the past week, I have read at least two complaints about OOP. The common gripes are how often it is ambiguous who should manage other objects’ lifecycles, how concurrency is hard, how mutable references spread around the codebase leads to scattered logic, et cetera. Two methods, one can’t be called before the other, and yet the call sites are several directories apart that you can’t be sure that one is always executed before the other.

Scattered business logic is confusing. To understand it, you need to shuffle through different files and modules. Not only that, it also undermines the type system's full potential. See making illegal states unrepresentable.

Some dismisses this is just a “bad design”; if you've written enough custom schedulers in your life and you'll develop the skill to anticipate scattered logic several steps ahead.

However, I insist this is a paradigm and language problem, since paradigm and language drives how people design software. Particularly how OOP-inspired modern languages focuses too much on structure and behavior. They don’t obsess enough about control of time.

When it comes to abstracting and encapsulating structure and behavior, there are lots of techhniques. In books and articles, it's always "dog is an animal and must implement 'talk'". When it comes to timing, async programming is second-class, async constructor is not a thing, concurrency primitives are afterthoughts, etc.

(btw, async in JS is cheating, and we’ll talk about it later)

Active Object

Imagine if an object takes complete care of its lifecycle. The callers won't need to manage the lifecycle. It can call any method anytime it wants without worry. Such an object acts like a microservice or a database engine, but it runs inside your app, written in the same language, and you don’t need IPC. If you’ve ever heard “backend for frontend”, this is “backend IN frontend”.

How do you make it? For a starter, you can design an active object:

  1. Create an async task or spawn a thread in the constructor; later, destroy it in the destructor.
  2. Expose methods. If necessary, have an atomic buffer to queue method calls ala the actor model mailbox.
  3. The inner async task is then responsible for processing the mailbox. Processing can be done by a simple spinlock or a more sophisticated waking mechanism.

Done!

You might often hear about DRY, separation of concern, Polymorphism. These things talk about putting structure and behavior in one place. It is encapsulated space control.

In contrast, what you have here is encapsulated time control. You can put a delay, batch, use SIMD or DOD-like operations, parallelize, etc. You can put whatever concurrency control you need. You can even abstract away, make the inner strategies parametrized via dependency injection.

Also, in JS, usually you wrap sync constructs inside an async construct. This allows you to do the inverse. It’s useful; you don’t have it every day.

(again, async in JS is cheating, we’ll come back to this)

(by the way I have just the right tool for this)

Syntactical Marker

"Doesn't that look like a class? The buffer is a private property, there are some methods exposed."

That's right! If you write it as a class or a closure, it will hard to differentiate passive classes or closures from the active ones. This is a language problem.

Historically, built-in syntactical markers for active objects and classes are rare. The closest one is Java’s implement Runnable. Without a syntactical marker of an “active” object, there’s no obvious way to know whether a class or an object is supposed to be active or passive.

A syntactical marker is important. I’d even radically argue that the object.method() syntax should be prioritized for active objects. Otherwise, use pipes of functions in FP style.

In other news, lower-level languages like C++ and Rust aren’t likely to afford to have built-in green threads. One must either use a library for it or you’re stuck with native thread. There is at least one valid case where you want to have the option to pick one or the other, which is portability. This would be a time control abstractions.

To provide the option, a syntactical marker is not enough. Active object implementation must be able to time itself without having to know about the threading solution its caller/owner uses. It needs an implementor-facing interface. Think of Rust’s future trait but much stronger, it should cover yield, sleep.

It’d be nice if the language’s syntax enables intuitive yielding where both the programmer and the compiler know intuitively when to yield in the middle of a block. Imagine having this feature out of the box:

agent TaskScheduler(context, self, otherParams: Params) {
  const inbox = [];
  const outbox = [];

  while (context.alive) {
    context.park();
    pipe(inbox.shift(), doHeavyCalculation, outbox.push);
  }

  pub const in = (message) => inbox.push(message);
  pub const next = () => outbox.shift() || null;
}

Lifetime

Let’s talk about lifetime.

In JS, active objects are tricky because there are no destructors; In Java, Kotlin, finalize is unreliable and deprecated. In c++, python destructors are called manually. We can get rid of the parent responsibility to manage most of an object's lifecycle except one, the lifetime. The parent needs to kill the children one way or another.

Rust partially solved this by tying an object's lifetime to a function scope. However, to exclusively own an object, one must be a scope or an object composition. You can’t have shared ownership with primary owners out of the box; you need extra code. Consequently, in Rust, a typical concurrent local program looks like this:

fn main() {
  let agents = initialize_agents();
  run(agents); // agents works and interacts together
  // drop(agents) - done automatically by rust
}

Be it concurrent agents or concurrent tasks, in Rust, F#, Haskell, it will look roughly like that.

Rust’s scope-lifetime does not cover hierarchical active objects but it is a nice concept otherwise. Exclusive ownership and lifetime should map one-to-one by default and a function can exclusively “own” an object.

Other languages don’t have this exact feature, but we can use the concept as a design pattern. For instance, exclusive ownership can be expressed in the type-system; the owner/parent has the only mutable value, and borrowers have the read-only value only. Then, we can use this to enforce a rule: active objects should be exclusively owned, constructed, and destroyed by its owner by default. Exclusive owner's destruction must be cascading.

Hierarchy syntax

Talking about hierarchy and lifetime, it should be something a front-end programmer is familiar with: React!

React made it very easy to create a component that is a child of another, and tie their lifetime. The child will not outlive its parent. You just have to render a component. This is about one of the best hierarchy syntaxes there is, in my opinion. It is a superset of JS/TS. It doesn't use weird templating like Vue or Angular.

Another example that I have heard about, but have not worked with, is Erlang's supervisor.

However good that programming model is, that’s about the end of the strong coupling because an effect within a component can continue running despite the component’s lifetime end. This is partially the consequence of the limitation of JS.

Another weak point is that the community seems to be insistent on using built-in hooks as primitives for React as a language, which will somehow go together with the React compiler they are experimenting with.

This pattern is nice for a shallow app. However, it doesn’t scale to complexity and performance. It is nicer to be able to write like this:

const GameUI = () => {
  const ref = useRef();
  // the bulk of the game's code is inside `Game`
  // including the main loop, re-rendering trigger, etc
  // `useActiveObject` ties `game`'s lifetime to `GameUI`'s
  const game = useActiveObject(() => Game(ref));
  const dialog = game.dialog();

  return (
    <>
      <canvas ref={ref}>
      {dialog && <Dialog data={dialog}>}
    </>
  )
}

The Paradigm So Far

So time control management needs to catch up to the more advanced space control management techniques:

  1. Active objects to group together, encapsulate, and abstract time control logic.
  2. Active objects need a different syntactical marker from passive objects.
  3. Lifetime management of children can’t be avoided in most if not all modern languages.
  4. Model things as exclusive ownership as much as possible, it’s easier to point out who is responsible for which lifetimes.
  5. Hierarchical ownership syntax helps making hierarchical things.

It’ll be nice if a language has those features. Until then, we’re stuck with annotating active objects with comments and/or naming conventions. Or you can use a library.

In JS

“It’s easy to make an active object in JS”, you think; and you’re right.

With JS, at any time and place, you’ll have access to the global scheduler with setTimeout, setInterval, requestAnimationFrame, queueMicrotask, etc. Those are where JS' async behavior truly starts; the rest is just a sync code wrapped in a promise. It is practically the implementor-facing interface. From the other programming language, it feels like a “cheat”.

The circumstances surrounding the global scheduler can also be a foot gun, though. For one, while(true){} will block all concurrent processes in the same thread. Two, with the built-in API, you can easily leave a lot of orphans, memory leaks, etc. We are not even talking about how the callback-based NodeJS APIs are designed on how everything being shared ownership by default doesn't help. My go-to technique is to wrap things this way:

// wrap this whole thing in a closure
() => {
  let alive = true;
  const buffer = [];

  // lifetime control and other methods, pass somewhere else
  const kill = () => (alive = false);
  const queue = (item: Item) => buffer.push(task);

  // the bulk of the code
  while (alive) {
    await yieldAndWaitForInterrupt();
    // do organize things here
  }

  return { kill, queue };
};

It doesn't use class because sometimes I don't want to use new when it is misleading. It doesn't look idiomatic. But I'd rather avoid callback hell and accidental orphans.

I have a library for it

A library doesn’t suffice to replace a language, always a best-effort. But here's one anyway for TypeScript: vaettir-react and vaettir.

It is basically a TypeScript library to build active objects with standardized destructor and customizable lightweight event emitters built-in. Vaettir.build acts as the syntactical marker. It has a peer dependency for React integration. It is also isomorphic, it can be used anywhere.