3
$\begingroup$

The inspiration for this question

There are many advantages of using monadic types like Promise<T> or Result<T> as function return types. Both promises and results are very elegant, explicit, and convenient approaches to their respective problems.

However, they create one issue. Each function that uses these types will need to wrap all their types in this monad. So you will end up with functions like:

fn f() -> Cancellable<Promise<Result<T>>> {
}

Perhaps with even more monads wrapped around it. Then every function that calls this function will also need to alter it's return type with to wrap it in all these monads all the way up. This has many disadvantages:

  • Less clarity in function declarations. You will create a super long expression instead of just the type you want to return
  • Needing to use some kind of unwrapping operator every time you use the return value of these functions. This complicates every function that needs to call a function that needs monad effects.
  • Whenever you need to change a function to add some monad you need to change every function that calls it which makes some changes very difficult.

Are there any approaches to gaining both the benefits of monads when you want them while allowing them to pass with little effort past functions that don't care about their inner workings? What languages use these approaches? What are their respective advantages and disadvantages?

$\endgroup$
2
  • 3
    $\begingroup$ I think it’s hard to answer this question at present because it’s not clear what the “benefits of monads” you’re referring to are. Monads are a pretty ridiculously general concept; they show up everywhere whether you choose to call them that or not. But I wouldn’t say that monads really provide benefits per se. $\endgroup$
    – Alexis King
    Commented Jul 5, 2023 at 21:07
  • $\begingroup$ @AlexisKing The benefits of results and promises are so different that it's hard to find anything that really ties them together $\endgroup$
    – mousetail
    Commented Jul 6, 2023 at 4:38

2 Answers 2

3
$\begingroup$

Combining instead of Nesting

To handle a similar problem, I'm implementing an effect composition mechanism for my language.

For such nested type functions, I envision a composite equivalent type:

fn async_call<T, U>(in: Future<T>) -> Cancellable<Future<Result<U, Error>>> { }

I envision a composite equivalent type:

def call_effect<T, U>(): U / [Request<T>, MissingNameError, Cancel] { }

The return value is divided into -> L/R two parts, the right part can be freely arranged and combined

According to your needs, for the same Error, if you write Error in the Result on the left, it will be nested, and if you write it on the right, it will be combined.

Use match for the left type and catch for the right type.

The specific usage is as follows:

struct Data { name: Option<String> }
effect Request() -> Data;
effect Cancel() -> bool;
effect MissingNameError() -> !; // prohibition to resume

def call_effect<T, U>() -> U / [Request<T>, MissingNameError, Cancel] {
    let cancel = raise Cancel()
    while !cancel {
        let data = raise Request();
        if data.name.is_none() {
            raise MissingNameError();
        }
        else {
            print("name is: { data.name }");
        }
        if random::<bool> {
            cancel = true;
            return U::default()
        }
    }
}

This part only needs to write the function logic and declare the various elements you need.

The specific processing method is determined by the caller. The caller can process all of them, or none of them, but if not processed, the continuation will be inherited.

def call_env<T>() -> U / [MissingNameError] {
    // Variables that can be modified by other threads
    let mut busy = false;
    // Define the filling scope of the handler
    try {
        effect_call();
        effect_call();
    }
    // Select the effects that need to be filled
    catch {
        case Request() => {
            delay(1000) { 
                resume T::default() 
            }
        }
        case Cancel() => {
            resume busy
        }
        // unfilled effects will result in additional continuation parameters
    }
    match { }
}

try { call_env() }
catch {
    case MissingNameError => {
        panic("MissingNameError")
    }
}

It can be found that this is more flexible, and does not need to be processed one by one in the reverse order of nesting.

What is the magic?

Essentially this is a CPS transformation.

In my language, all functions have no return value at the HIR level.

After analysis, it will first be transformed into the following form:

def call_cps<T, U>(
    return: Continuation<U>, 
    error: Continuation<MissingNameError>,
    cancel: Continuation<bool>,
    request: Continuation<T>,
)
{
    // mutable reference, but not mutable variable
    let cancel = cancel()
    loop {
        if !cancel {
            break
        }
        let data = request();
        if data.name.is_none() {
            error();
        }
        else {
            print("name is: { data.name }");
        }
        if random::<bool> {
            cancel = true;
            return U::default()
        }
    }
}

The caller then optionally processes the neutralized continuation parameters:

// Wait for higher layers to handle this error
def call_env<T, U>(return: Continuation<U>, error: Continuation<MissingNameError>) {
    let mut busy = false;
    call_cps(
        return: return,
        request: {
            delay(1000) { 
                T::default() 
            }
        }
        calcel: {
            @reference(busy)
        }
        error: error
    )
    // another same call
    call_cps(...)
}

It can be found that the source of composition comes from the continuation as a parameter, and the order can be changed arbitrarily.

$\endgroup$
1
  • $\begingroup$ This presentation is essentially the mother-of-all-monads approach. $\endgroup$
    – Corbin
    Commented Nov 21, 2023 at 16:27
2
$\begingroup$

Type aliases:

type MaybeLater<T> = Cancellable<Promise<Result<T>>>

Generally, if the problem is "there are some pieces of code that are long/noisy that I have to type over and over again", the easy solution is to let the user declare a shorthand for it.

This doesn't solve all of the issues you raise, but it is a very cheap addition to the language.

$\endgroup$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .