Skip to main content

Control flow (Part 2)

· 26 min read

Higher abstractions

Control flow (Part 1)

Control flow (Part 2)

In Part 2, we delve deeper into control flow techniques. This time, we focus on code design skills rather than relying on tools integrated into the compiler/language. As a result, these techniques can be applied to any modern programming language or runtime of choice. While some of them are recognized as patterns, others involve manipulating language rules in a more nuanced way. Regardless, all of them merit our attention as novel approaches to resolving common programming challenges.

Intro

As we venture deeper into the terrain of control flow tools, we enter the realm of higher-level abstractions in software design. The aim of this domain is to offer solutions that facilitate communication and coordination among different components of a program. The communication facet concerns the methods of articulating computations as types and establishing execution flow around those types. Meanwhile, the coordination aspect provides a means for various concurrent processes or threads to intercommunicate and cooperate with one another. We briefly touched upon the concept of light threads in Part 1. In this section, we will delve further into the methods of coordinating them.

Algebraic effects

Let's look at our original example computation from Part 1:

function divide(x, y) {
if (y === 0) {
throw new Error("Division by zero");
} else {
return (x / y);
}
}

It is pretty clear that this method does not always behave the way we might expect it to.

For example,

divide(10,0);

does not produce a value (throws an exception). What this tells us is the computation has the ability to fail. And when it fails, divide completely breaks its signature contract by "saying" that it was not suppose to be invoked with such parameters in the first place. In comparison to a regular execution flow, this behavior could be considered as a side effect - an additional (potentially hidden) behavior that has to be treated separately.

In computer science, an operation, function or expression is said to have a side effect if it modifies some state variable value(s) outside its local environment, which is to say if it has any observable effect other than its primary effect of returning a value to the invoker of the operation. Example side effects include modifying a non-local variable, modifying a static local variable, modifying a mutable argument passed by reference, performing I/O or calling other functions with side-effects. Wikipedia

Sounds like having side effects is not a good idea. Why do we need to cater for additional special cases? Wouldn't it be nicer to have one common way of dealing with all possible "results" of the divide method built into the language? Unfortunately, JavaScript does not provide such luxury out of the box. And the reason for that has nothing to do with the exception throwing - catching syntax.

Imagine designing a new language from scratch (or work on an new version of ECMAScript with no restrictions on backward compatibility). A language that does not have any side effects, where every function always returns a value, no matter what set of parameters is it invoked with. How would we approach this task? There are a few things we need to take notice of here:

  • Side effects by themselves are very useful. After all, what would be the use of a language that does not have an ability to read data from a file, send HTTP request or generate random numbers? It's not the side effects. It's the language that has to let us express them as part of its semantics.
  • Language with no side effects has to incorporate them into its type system. It is simply the only option (unless it's a language with special syntax dedicated to side effects). Scalability of the program code relies on a composition of the parts the code was split into. Those parts interact using instances of some types. Those types have to embed side effects to be composable to give the ability for more complex algorithms. And the key of achieving this is the flexibility/variability of the type system.

So the success of designing such language relies on a success of designing an appropriate type system.

In computing, an effect system is a formal system that describes the computational effects of computer programs, such as side effects. An effect system can be used to provide a compile-time check of the possible effects of the program. The effect system extends the notion of type to have an "effect" component, which comprises an effect kind and a region. The effect kind describes what is being done, and the region describes with what (parameters) it is being done. An effect system is typically an extension of a type system. The term "type and effect system" is sometimes used in this case. Often, a type of a value is denoted together with its effect as type!effect, where both the type component and the effect component mention certain regions (for example, a type of a mutable memory cell is parameterized by the label of the memory region in which the cell resides). The term "algebraic effect" follows from the type system. Wikipedia

And this is where we get our clue. If we manage to represent our type system semantics using the idea that programs and the data they manipulate are symbolic representations of abstract mathematical objects, we get embedded side effects and compositionality out of the box.

Denotational semantics

In computer science, denotational semantics is an approach of formalizing the meanings of programming languages by constructing mathematical objects (called denotations) that describe the meanings of expressions from the languages. Wikipedia

Compositionality is achieved by denotational semantics relying on the recursive power of mathematical expressions where the semantics of terms constructed from sub-terms is correspondingly built from the semantics of these sub-terms.

Seems like we are getting all parts of the puzzle together. But in this case, why do languages like JavaScript have a lack of side effects management constructs? Because there is no denotational semantics that corresponds to the language semantics. So what sort of semantics does this language semantics correspond to?

Operational semantics is a category of formal programming language semantics in which certain desired properties of a program, such as correctness, safety or security, are verified by constructing proofs from logical statements about its execution and procedures, rather than by attaching mathematical meanings to its terms (denotational semantics). Operational semantics are classified in two categories: structural operational semantics (or small-step semantics) formally describe how the individual steps of a computation take place in a computer-based system; by opposition natural semantics (or big-step semantics) describe how the overall results of the executions are obtained. Wikipedia

Hence, the particular way of dealing with exceptions in JavaScript by catching them at any arbitrary level that, potentially has nothing to do with the original exception. One try - catch block might not seem like a big deal, but how about a few nested blocks? Do you see the similarity with the pyramid of callback hell? And there is no much we can do about it (it's language semantics). But what we can do is make side effects expressed as algebraic effects. By doing so, we explicitly combine computation result and the side effect in one type with rules around how the effect could be handled.

Algebraic effect

An algebraic effect refers to an operation that potentially involves side-effects. The concept revolves around the notion that any function requiring such an operation must explicitly express it, making it dependent on the corresponding effect statically (compiler inferrable). Whenever a function necessitating a specific algebraic effect is called, the caller must either require the same effect or provide a handler that implements it.

Back to our original divide. First attempt is strait forward:

function divide(x, y) {
return (x / y);
}

No exception - no problem? No exactly ... Yes, we get the result for all possible valid inputs and that result could be reused anywhere else in the program. But with the exception removal we also lost a portion of semantics:

console.log(divide(10,0));          //Infinity
console.log(divide(Infinity, 10)); //Infinity

For the second attempt, let's make the result being explicit:

function divide(x, y) {
return y === 0 ?
{ value: null, error: new Error("Division by zero") } :
{ value: x / y, error: null };
}

const { value, error } = divide(10, 0);
if (error) {
console.error(error);
} else {
console.log(value);
}

This is a bit of a shortcut implementation. The result is explicit now, but we enforce every caller to know how to deal with it. What we are missing is a general purpose handler that deals with all the effects raising exceptions. The explicitness of the result embedded within a type and a handler designed to process such results is what makes a side affect and algebraic one.

const EffectEx = (message) => ({
isExEffect: true,
message
});

const raise = (message) => EffectEx(message);

function divide(x, y) {
return y === 0 ? raise("Division by zero") : (x / y);
}

function catchEx(action, handler) {
const result = action();

return result.isExEffect ? handler(result.message) : result;
}

function divWithInfinity(x, y) {
return catchEx(
() => divide(x, y),
(s) => { console.log(s); return Infinity}
);
}

const result = divWithInfinity(10, 0);
console.log(result);

Or using stack shifting technique with learned from Part 1:

const EffectEx = (message) => ({
isExEffect: true,
message
});

const raise = (message) => { throw EffectEx(message); }

const callCC = (f, handler) => {
try {
return f();
}
catch(ex){
if(ex?.isExEffect) {
return handler(ex.message);
}

throw ex;
}
}

function divide(x, y) {
return y === 0 ? raise("Division by zero") : (x / y);
}

const result = callCC(
() => divide(10, 0),
(s) => { console.log(s); return Infinity; }
);

console.log(result)

And, since we started treating throw - catch construct as a tool for control flow manipulation, wny not to make it work at its full potential, both synchronously and asynchronously:

const EffectEx = (message) => ({
isExEffect: true,
message
});

const raise = (message) => { throw Promise.reject(EffectEx(message)); }

function divide(x, y) {
if(y === 0) {
raise("Division by zero");
}

return (x/y);
}

async function run(action, handler) {
try {
return action();
} catch (e) {
if(e instanceof Promise) {
const inner = await e.catch(x => x);

if(inner.isExEffect){
return handler(inner.message);
}

return inner;
}

throw e;
}
}

const div = () => {
const result = divide(10, 0);
return `Result: ${result}`;
}

run(
div,
(e) => `Exception: ${e}`
)
.then(console.log)

Regardless of the implementation details, div knows nothing about the underlining impurity of the divide. In the context of the div, divide always returns "convenient" values. The entire effect is taken out and handled separately in a composable way (hence the promises).

Speaking of compositionality, nice thing about algebraic effects is that not ony handlers could be designed to handle anything, they can also transform handled value in any way to make later computation fit precisely.

const EffectEx = (message) => ({
isExEffect: true,
message
});

const raise = (message) => { throw Promise.reject(EffectEx(message)); }

function divide(x, y) {
if(y === 0) {
raise("Division by zero");
}

return (x/y);
}

async function run(action, handler) {
try {
return handler(action());
} catch (e) {
if(e instanceof Promise) {
const inner = await e.catch(x => x);

if(inner.isExEffect){
return handler();
}

throw inner;
}

throw e;
}
}

const Nothing = () => () => {}
const Just = (value) => () => value

const $case = (caseOf) =>
(maybe) => {
const value = maybe();

// for the lack of native pattern matching in JavaScript
return (value == null || value === undefined) ?
caseOf.nothing() :
caseOf.just(value)
}

const handler = (value) => value === undefined ?
Nothing() :
Just(value);

const div = () => {
const result = divide(10, 0);
return result;
}

run(div, handler)
.then($case({
nothing: () => "Division by zero",
just: (value) => `Result: ${value}`
}))
.then(console.log);

With the last example we are slowly getting into the territory of side effect management that competes with algebraic effects. So I am going to stop it there. (perhaps an article for another day). Till then, here is a thought to slip better through the night:

. . . the use of monads is similar to the use of effect systems . . . . An intriguing question is whether a similar form of type inference could apply to a language based on monads.

Delimited effects

Arguably, exception effect might be the most illustrative one, but it is not the only one that can be handled algebraically. In fact, exception effect is a special one. It's a break in the flow that dismisses the rest of the computation. Luckily, algebraic effects exist to handle all sorts of effects, including those that pause and resume computations.

In general, anything can be considered an effect during the program flow execution. When the main flow needs to be interrupted or aborted completely, it could be managed in an algebraic fashion.

Pausing and resuming computations? Sound like something we have seen before. That's right - generators. We can reuse generators for splitting the computation into parts so we can sort them into those that have effects and do not have effects. Having multiple different effects implies having multiple handlers. And those handlers need to be executed. We could implement a global dispatcher kind of method that connects corresponding effects and their handlers execution, but instead we are going to use good old OOP feature - encapsulation. When the handler is encapsulated with its effect, executing it becomes a trivial task.

const EffectEx = (message) => Object.freeze({
isEffect: true,
isException: true,
handler: () => console.log(message)
});

const EffectInput = (value) => Object.freeze({
isEffect: true,
handler: () => value
});

const EffectOutput = (message) => Object.freeze({
isEffect: true,
handler: () => console.log(message)
});

const simulatedInput = (value) => function* () {
yield EffectOutput('Enter number:');
return yield Promise.resolve(EffectInput(value))
}

const consoleInput = function* () {
const readline = require('readline');
const instance = readline.createInterface({
input: process.stdin,
output: process.stdout
});

return yield new Promise(resolve => {
instance.question('Enter number: ', function(value) {
instance.close();
resolve(EffectInput(Number(value)));
});
});
}

function* output(message) {
yield EffectOutput('Result: ');
yield EffectOutput(message);
}

function* raise(message) {
yield EffectEx(message);
}

function* divide(x, y) {
if(y === 0) {
return yield* raise('Division by zero');
}

return yield (x/y);
}

const program = (input) => function* () {
const y = yield* input();
const result = yield* divide(10, y);
yield* output(result);
}

async function run(generator) {
const iterator = generator();

let result = iterator.next();

try {
while(!result.done) {
const step = await Promise.resolve(result.value);

if (step?.isEffect) {

if(step?.isException) {
throw result.value;
}

const value = step.handler();
result = iterator.next(value);
} else {
result = iterator.next(step);
}
}
} catch(e) {
if(e?.isEffect && e?.isException) {
e.handler();
return;
}

throw e;
}
}

run(
program(simulatedInput(1))
);

Have you noticed how the "simulated input" effect was passed to the program? Changing that line to any another input effect implementation could change the whole semantics of the input to console input via consoleInput or the HTTP input via an HTTP request effect implementation.

As we can see, algebraic effects allow for more modular and composable code, as effects can be defined separately from the code that uses them. This makes it easier to reason about the behavior of a program, as well as to test and maintain it. Additionally, algebraic effects can be used to implement more advanced language features such as coroutines, continuations, and concurrency constructs.

Channels

Despite that lots of computations could be represented as effects, it is not always convenient to use algebraic effects for computation management. In larger systems, decoupling different computations (often multiple) is achieved by coordinating them in a controllable way. This is something that algebraic effects do, but they are specific to the problem they solve (different effects for different situations). Coordination could be solved in a more general manner. Usual approach involves some sort of message that computations pass to each other with no explicit knowledge of a sender and receiver.

In computing, a channel is a model for interprocess communication and synchronization via message passing. A message may be sent over a channel, and another process or thread is able to receive messages sent over a channel it has a reference to, as a stream. Different implementations of channels may be buffered or not, and either synchronous or asynchronous.Wikipedia

Channels provide a way for different parts of a program to communicate and synchronize with each other without the need for direct coupling. For example, one part of a program could produce data and add it to a channel, while another part of the program consumes that data and processes it. (Producer - consumer problem).

Channels are often used in concurrency scenarios, such as in web workers or web servers that needs to handle multiple requests concurrently. By using channels, multiple tasks can be processed in parallel, with each task communicating with other tasks via the channel. A channel can be thought of as a queue of values, where values can be added to the end of the queue and removed from the front of the queue (FIFO queue):

function* divide() {
let count = 0;

const queue = [];

while(true) {
const value = yield;

queue.push(value);
count++;

if(count % 2 === 0) {
const x = queue.shift();
const y = queue.shift();
count = 0;

yield y === 0 ?
new Error('Division by zero') :
(x/y);
}
}
}

const div = divide();
div.next();
div.next(10);
console.log(div.next(2).value);

div.next();
div.next(20);
console.log(div.next(0).value);

For this example, the part of the program that produces the data and the part the consumes results are the same. Splitting producer and consumer apart and adding promises give us more decoupling and the ability to process computations asynchronously:

const Channel = () => {

const queue = [];
let resolver = null;
let promise = new Promise(resolve => {
resolver = resolve;
});

return {
async send(value) {
if(queue.length < 1) {
queue.push(value);
return;
}

const x = queue.shift();
const y = value;
const result = y === 0 ?
new Error('Division by zero') :
(x/y);

const prev = resolver;
resolver = null;
await prev(result);
promise = new Promise((resolve) => {
resolver = resolve;
});
},

async receive() {
const value = await promise;
promise = new Promise((resolve) => {
resolver = resolve;
});

return value;
}
}
}

const divide = Channel();

async function sender() {
await divide.send(10);
await divide.send(2);

await divide.send(10);
await divide.send(0);
}

async function receiver() {
let value = await divide.receive();
console.log(value);

value = await divide.receive();
console.log(value);
}

sender();
receiver();

Bidirectional channels

When a producer-consumer pattern is implemented via a channel, producer always sends (generates) data, consumer always receives (consumes) data. The channel itself could be considered as a bucket of messages waiting to be transmitted to the destination. The bucket does not have a direction of message transmission. But because data travels from one part of the application to another, it gives channel a direction making it unidirectional. There are situations where bidirectional communication is more preferable. For those cases, two channels with the opposite direction could be used.

const Channel = () => {

let resolver = null;
let promise = new Promise(resolve => {
resolver = resolve;
});

return {
async send(value) {
const prev = resolver;
resolver = null;
await prev(value);
promise = new Promise((resolve) => {
resolver = resolve;
});
},

async receive() {
const value = await promise;
promise = new Promise((resolve) => {
resolver = resolve;
});

return value;
}
}
}

const channelIn = Channel();
const channelOut = Channel();

const sendMessageForward = async (x, y) => {
await channelIn.send(x);
await channelIn.send(y);

return await channelOut.receive();
}

const sendMessageBackward = async () => {
const x = await channelIn.receive();
const y = await channelIn.receive();

await channelOut.send(
y === 0 ? new Error('Division by zero') : (x/y)
);
}

async function sender() {
let value = await sendMessageForward(10, 5);
console.log(value);

value = await sendMessageForward(10, 0);
console.log(value);
}

async function receiver() {
await sendMessageBackward();
await sendMessageBackward();
}

Promise.all([sender(), receiver()]);

Channel chaining

Using multiple channels for communication is a wide used pattern and not only for sending messages in opposite directions. Channels can send and receives messages from other channels forming chains (pipes). The only rule here is one channel can send data to a single channel as well as received data from single channel. No multiple channels are allowed for sending/receiving same message in parallel. The chain is formed by attaching channels to each other side by side. In this case channels could be used to transmit data via different types of wires (for example HTTP channel), or to transform data being passed through the channel or both:

const Channel = (transform = null) => {
const queue = [];
let receiveResolve = null;

return {
send: (value) =>
new Promise(resolve => {
if(queue.length <= 0 && receiveResolve) {
receiveResolve(transform ? transform(value) : value);
receiveResolve = null;
} else {
queue.push(transform ? transform(value) : value);
}

resolve();
}),

receive: () =>
new Promise(resolve => {
if(queue.length > 0) {
resolve(queue.shift());
return;
}

receiveResolve = resolve;
})
}
}

async function createPipe(...channels) {
let result = Promise.resolve();

while(channels.length > 0) {
const channel1 = channels.shift();
const channel2 = channels[0];

if(channel2) {
result = result
.then(() => channel1.receive())
.then(value => channel2.send(value));
}
}

await result;
}

async function pipe(...channels) {
while(true){
await createPipe(...channels);
}
}

async function sender(channel) {
channel.send(10);
channel.send(0);
}

async function receiver(channel) {
while(true){
const value = await channel.receive();
console.log(value);
}
}

const channel1 = Channel();
const channel2 = Channel((x) => [10, x]);
const channel3 = Channel(([x, y]) =>
y === 0 ? new Error('Division by zero') : (x/y));

pipe(channel1, channel2, channel3);
receiver(channel3);
sender(channel1);

Some benefits of using channels:

  • Compositionality - Same channel type can be used in multiple pipes where every pipe has its own instance of the channel. This gives us infinite amount of ways to compose channels into pipes.
  • Testability - Every channel operates as an individual unit, hence it is tested independently of the rest of the channels it communicates with. Types of tests are determined by the type of actions performed by a channel. For example, request-response handling, pipeline processing, or message passing between threads or processes.
  • Decoupling - producer and consumer of the messages are decoupled by a channel. Coupling between then exists only at the message contract level.

Actors

Channels can pass messages only in unidirectional way. This model limitation has its own benefits, simplicity being juts on of them. However, there are more sophisticated models that do not have such restriction and use its absence to their benefits. Enter actors:

The actor model in computer science is a mathematical model of concurrent computation that treats an actor as the basic building block of concurrent computation. In response to a message it receives, an actor can: make local decisions, create more actors, send more messages, and determine how to respond to the next message received. Wikipedia.

In actor model everything is an actor. There are no computations performed outside of actors. The model imposes a few rules on th actor itself as well:

  • Actors can send 0 to finite number of messages to different actors (including itself in a form of recursive style processing).
  • Actors can create finite number of actors.
  • Communication between actors is performed only through direct asynchronous message passing.
  • Recipient actors can be determined ony by addresses. Actors's address can be obtained from the message or by assigning and address to a newly created actor.

An actor is an entity that encapsulates state and behavior. It is an independent, isolated unit of computation that communicates with other actors through messages. Actors can be used to implement concurrent and distributed systems, where different parts of the system communicate and coordinate with each other.

const Actor = (receiveMessage) => {
const mailbox = [];
let isProcessingMailbox = false;

const processMailbox = async () => {
if (isProcessingMailbox) {
return;
}

isProcessingMailbox = true;

while (mailbox.length > 0) {
const message = mailbox.shift();
await receiveMessage(message);
}

isProcessingMailbox = false;
}

return {
send(message) {
mailbox.push(message);
processMailbox();
},

receive: receiveMessage,
}
}

const DivideActor = () => {

const receive = async (message) => {
if(message.type !== 'divide') {
return;
}

const {x, y} = message.data;

if(y === 0) {
await message.error
.receive({
type: 'error',
data: new Error('Division by zero')
});

return;
}

const result = x/y;
await message.sender
.receive({
type: 'result', data: result
});
}

return Actor(receive);
}

const ErrorActor = () => {

const receive = async (message) => {
if(message.type !== 'error') {
return;
}

const error = message.data;
console.log(`Exception: ${error}`);
}

return Actor(receive);
}

const RunActor = () => {

const result = Actor(receive);

async function receive(message) {

switch(message.type) {
case 'result':
console.log(`Result: ${message.data}`);
break;

case 'divide':
message.sender.receive({...message, sender: result });
break;
}
}

return result;
}

const run = RunActor();
const divide = DivideActor();
const error = ErrorActor();

run.send({
type: 'divide',
sender: divide,
error,
data: { x: 10, y: 2 }
});

run.send({
type: 'divide',
sender: divide,
error,
data: { x: 10, y: 0 }
});

To accommodate for parallel computations, actors can create different actors and use them as child computations. In this case children perform their actions independently and in parallel. Let's see how we can implement fan-out pattern using child actors:

const Actor = (receiveMessage) => {
const mailbox = [];
const children = [];
let parent = null;
let isProcessingMailbox = false;

const processMailbox = async () => {
if (isProcessingMailbox) {
return;
}

isProcessingMailbox = true;

while (mailbox.length > 0) {
const message = mailbox.shift();
await receiveMessage(message);
}

isProcessingMailbox = false;
}

return {
send(message) {
mailbox.push(message);
processMailbox();
},

receive: receiveMessage,

spawn(child) {
children.push(child);
child.setParent(this);
},

setParent(parent) {
parent = parent;
},

async sendParent(message) {
if (parent) {
await parent.send(message);
}
},

async sendChildren(message) {
const promises = children
.map(child => child.send(message));

await Promise.all(promises);
}
}
}

const GetNumberActor = (number, type) => {

const receive = async (message) => {
if(message.type !== 'number') {
return;
}

await message.sender.receive({
type,
data: number,
receiver: message.receiver,
error: message.error
});
}

return Actor(receive);
}

const DivideActor = () => {

const stackX = [];
const stackY = [];

const divide = async (message) => {

if(stackX.length <= 0 || stackY.length <= 0) {
return;
}

const x = stackX.pop();
const y = stackY.pop();

if(y === 0) {
await message.error
.receive({
type: 'error',
data: new Error('Division by zero')
});

return;
}

const result = x/y;
await message.receiver.receive({
type: 'result',
data: result
});
}

const receive = async (message) => {

switch(message.type) {
case 'x':
stackX.push(message.data);
break;

case 'y':
stackY.push(message.data);
break;
}

await divide(message);
}

return Actor(receive);
}

const ErrorActor = () => {

const receive = async (message) => {
if(message.type !== 'error') {
return;
}

const error = message.data;
console.log(`Exception: ${error}`);
}

return Actor(receive);
}

const RunActor = () => {

async function receive(message) {

if(message.type === 'result') {
console.log(`Result: ${message.data}`);
}
}

return Actor(receive);
}

const run = RunActor();
const getX = GetNumberActor(10, 'x');
const getY = GetNumberActor(2, 'y');
const getX1 = GetNumberActor(10, 'x');
const getY1 = GetNumberActor(0, 'y');

const divide = DivideActor();
const error = ErrorActor();

run.spawn(getX);
run.spawn(getY);
run.spawn(getX1);
run.spawn(getY1);

run.sendChildren({
type: 'number',
sender: divide,
receiver: run,
error
});

Practical example

For our final example we are going to manage control flow with the knowledge we gathered so far. Let's take for a spin a classical architecture: client - server.

Client

Client is a web application running in the browser. It is implemented using React with a control management using Actors model.

Actors list:

  • Run actor - hosts the rest of the actors and behaves as a communication bridge between them and React plumbing (hooks).
  • Environment actor - receives environment request messages and sends back all environment related variables.
  • Validate actor - validates user input.
  • Divide actor - makes HTTP requests to the server via REST API and retrieves results.
  • Error actor - behaves as a facility to display error messages from various actors.

Server

Server application is a dotnet core runtime running behind a kestrel web sever. Control management is implemented using Channels model.

Channels list:

  • Validate channel - Validates request body.
  • Worker channel - Performs active operation (divide).
  • Response channel - Returns response to the API caller by writing it into HttpResponse.

Actors channels example

Source code

Reference implementation

Conclusion

Algebraic effects are a programming language feature that allows for a modular and composable approach of handling side effects. In essence, an algebraic effect is a way of specifying the possible side effects that a program may have, and then handling those effects in a controlled and predictable manner.

In more technical terms, an algebraic effect is a computational effect that can be expressed as a sum type, where each constructor of the sum type represents a particular effect. When a program executes an operation that has a side effect, it can do so by invoking one of these constructors, which signals to the runtime that a particular effect has occurred. The runtime can then handle that effect in a controlled way, such as by running a specific handler function that knows how to deal with that effect.

Channels are typically used in message-passing concurrency models, where data is sent between processes via channels. Channels can be used to establish a communication link between different concurrent processes and to coordinate the order of execution of tasks.

Actors are another type of concurrency model where each actor is an independent, isolated unit of computation that communicates with other actors through messages. Actors can be used to model complex systems where multiple concurrent processes interact with each other in a decentralized and loosely-coupled way.

Algebraic effects are a way of encapsulating side effects and controlling their sequencing, making it easier to reason about concurrent programs. Channels and actors, on the other hand, are communication primitives that provide a way for different concurrent processes or threads to communicate and coordinate with each other.

References

Side effect

Effect system

Denotational_semantics

Operational_semantics

Algebraic effect

Algebraic effects for Functional Programming

The marriage of effects and monads

Encapsulation

Message Channels

Fan-out pattern

Actor model