Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions kyo-prelude/shared/src/main/scala/kyo/Guard.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package kyo

import kyo.Guard.CanGuard
import kyo.kernel.ArrowEffect

/** Protect effect(s) [[S]] from being handled. To be used in functions that accept effects with generic effect types in order to avoid
* accidentally handling them. Can only be used within [[Guard.use]] blocks.
*
* @tparam S
* The effect type that is guarded
* @see
* [[Guard]]
*/
sealed trait Guarded[S] extends ArrowEffect[[A] =>> A < S, Id]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is quite useful! would it cover the Abort case as well? I think not because of the erased tags, right?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe Mask for the name could work better? It seems to express well what the effect does since it masks the effect tags. We have fiber.mask but it's a rare low-level operation so the name isn't use yet

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't cover the Abort case because it cannot distinguish between different effect types included in an effect intersection. I changed Guard to Mask though.


/** Utility for protecting any effect of type [[S]] from being handled
*
* @tparam S
* The effect type that can be guarded
*/
sealed class Guard[S](using tag: Tag[Guarded[S]]):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we avoid allocating this class? I think something like this could work:

opaque type Guard[S] = Unit

object Guard:
  extension [S](self: Guard[S])
     def apply[A](effect: A < S)(using CanGuard, Frame, Tag[Guard[S]]): A < Guarded[S]

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done


/** Guard an effect from being handled, wrapping its effect type [[S]] with [[Guarded]]
*
* @param effect
* The effect to be guarded
* @return
* A new effect with its effect intersection protected from handling
*/
def apply[A](effect: A < S)(using CanGuard, Frame): A < Guarded[S] =
ArrowEffect.suspend[A](tag, effect)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not only this approach composes better and has ContextEffect support but it's also is cheaper than the version I had suggested! My version would reinterpret all suspensions within the guarded computation, this version just suspends once 👏

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It suspends once because it guards the full effect. While I find it very, very elegant, I still want to see what happen on the cases we would need.

def apply[A, S2](effect: A < (S & S2))(using CanGuard, Frame): A < (Guarded[S] & S2)

(which your version was probably doing)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this is the big limitation of my approach. One implication is that you can't fork Guarded effects because it's not possible to construct an Isolate for it.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe with something that "just" filter/rewrite tags when needed?(that would be a change in the kernel to pass an actual Mask/Guard on tags)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The big problem is that ContextEffect suspensions are simply KyoDefer instances without any tags identifying the effect type, so it's not possible to identify and mask them. To support a more general masking effect, we would need to allow including tags on KyoDefer, or add a new Kyo subtype specifically for context effects.

end Guard

object Guard:
sealed abstract class CanGuard
private val canGuardInstance = new CanGuard {}

private def run[S](using Frame)[A, S1](effect: A < (Guarded[S] & S1))(using tag: Tag[Guarded[S]]): A < (S & S1) =
ArrowEffect.handle(tag, effect)(
handle = [C] => (input, cont) => input.map(c => cont(c))
)

/** Use a [[Guard]] instance to protect one or more effects with effect type [[S]] from being handled within the scope of a provided
* function by lifting them to [[Guarded]][S]. To be used in functions that accept effects with generic effect types in order to avoid
* accidentally handling them. The guarded type [[Guarded]][S] is handled to [[S]] at the end of the scope.
*
* Example:
*
* def genericFunction[S](effect: Int < S): Int < S = Guard.use: guard => Abort.fold(identity, _ => 0): // Converts failure to 0
* guard(Kyo.zip(effect, effect)).map: case (i, j) => val result = i + j if result < 0 then Abort.fail("negative!") else i + j
*
* Abort.run[String](genericFunction(Abort.fail("failed!"))).eval // Result: Result.Failure("failed!") <-- failure is not converted to
* 0
*
* @tparam S
* Effect type intersection of the effects to be guarded within the scope of [[f]]
* @param f
* Function that uses a [[Guard]][S] instance to protect one or more effects from being handled
* @return
* An effect with the original guarded effect(s) [[S]] along with any additional effects [[S1]] accumulated when using them
*/
def use[S](using Tag[Guarded[S]], Frame)[A, S1](f: CanGuard ?=> Guard[S] => A < (Guarded[S] & S1)): A < (S & S1) =
given CanGuard = canGuardInstance
val guard = Guard[S]
val result = f(guard)
Guard.run[S](result)
end use
end Guard
95 changes: 95 additions & 0 deletions kyo-prelude/shared/src/test/scala/kyo/GuardTest.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package kyo

class GuardTest extends Test:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add tests with multiple effects? It'd be nice to have scenarios where only part of the effects are guarded

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This approach does not support masking specific effects within a larger effect intersection. I added a test with multiple effects though.


"unprotected arrow effect" in {
def genericFn[S](intEffect: Int < S)(using Tag[S]): Int < S =
Var.run(2):
Var.get[Int].map: i =>
intEffect.map: j =>
i + j

val eff = Var.update[Int](_ + 1)

assert(Var.run(10)(genericFn(eff)).eval == 5)
}

"protects arrow effect" in {
def genericFn[S](intEffect: Int < S)(using Tag[S]): Int < S =
Guard.use[S]: guard =>
Var.run(2):
Var.get[Int].map: i =>
guard(intEffect).map: j =>
i + j

val eff = Var.update[Int](_ + 1)

assert(Var.run(10)(genericFn(eff)).eval == 13)
}

"unprotected context effect" in {
def genericFn[S](intEffect: Int < S)(using Tag[S]): Int < S =
Env.run(5):
Env.get[Int].map: i =>
intEffect.map: j =>
i + j

val eff = Env.get[Int]

assert(Env.run(10)(genericFn(eff)).eval == 10)
}

"guard protects context effect" in {
def genericFn[S](intEffect: Int < S)(using Tag[S]): Int < S =
Guard.use[S]: guard =>
Env.run(5):
Env.get[Int].map: i =>
guard(intEffect).map: j =>
i + j

val eff = Env.get[Int]

assert(Env.run(10)(genericFn(eff)).eval == 15)
}

"unprotected complex effect" in {
def genericFn[S](intEffect: Int < S)(using Tag[S]): Int < S =
Env.run(5):
Env.get[Int].map: i => // 5
intEffect.map: j => // 25
Var.run(2):
Var.get[Int].map: k => // 2
intEffect.map: l => // 7
i + j + k + l

val eff = Env.get[Int].map(i => Var.get[Int].map(j => i + j))

assert(Env.run(10)(Var.run(20)(genericFn(eff))).eval == 39)
}

"guard protects complex effect" in {
def genericFn[S](intEffect: Int < S)(using Tag[S]): Int < S =
Guard.use[S]: guard =>
val guardedEffect = guard(intEffect)
Env.run(5):
Env.get[Int].map: i =>
guardedEffect.map: j =>
Var.run(2):
Var.get[Int].map: k =>
guardedEffect.map: l =>
i + j + k + l

val eff = Env.get[Int].map(i => Var.get[Int].map(j => i + j))

assert(Env.run(10)(Var.run(20)(genericFn(eff))).eval == 67)
}

"cannot be used outside of scope" in {
def genericFunction[S](using Tag[S]): Guard[S] < S =
Guard.use[S](g => g)

assertDoesNotCompile:
"""genericFunction[Any].map(guard => guard(42))"""
}

end GuardTest
Loading