Skip to content

Commit

Permalink
Show type class (#914)
Browse files Browse the repository at this point in the history
- `Show[A]` type class to generate string representations of types
  - Generic derivation for ADTs
  - Default instance for other instances using `toString`
- `Show` instances for `Maybe`, `Result`, and `<` displaying wrappers
- String interpolator `k` to use `Show` to construct strings
- E.g., `println(k"hello ${Maybe.Present("world")}")` produces `hello
Present(world)` instead of `hello world`

---------

Co-authored-by: Adam Hearn <22334119+hearnadam@users.noreply.github.com>
  • Loading branch information
johnhungerford and hearnadam authored Dec 13, 2024
1 parent 6ace1ab commit 13ed174
Show file tree
Hide file tree
Showing 12 changed files with 404 additions and 28 deletions.
96 changes: 96 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,46 @@ val b: Result[Throwable, Int] =
KyoApp.Unsafe.runAndBlock(2.minutes)(a)
```

### Displaying Kyo types

Due to the extensive use of opaque types in Kyo, logging Kyo values can lead to confusion, as the output of `toString` will often leave out type information we are used to seeing in boxed types. For instance, when a pure value is lifted to a pending computation, you will see only that value when you print it.

```scala
import kyo.*

val a: Int < Any = 23
Console.printLine(s"Kyo effect: $a")
// Ouput: Kyo effect: 23
```

This can be jarring to new Kyo users, since we would expect a Kyo computation to be something more than just a pure value. In fact, Kyo's ability to treat pure values as effects is part of what makes it so performant. Nevetheless, the string representations can mislead us about the types of values we log, which can make it harder to interpret our logs. To make things clearer, Kyo provides an `Render` utility to generate clearer string representation of types:

```scala
import kyo.*

val a: Int < Any = 23

val aStr: Text = Render.asText(a)

Console.printLine(s"Kyo effect: $aStr")
// Output: Kyo effect: Kyo(23)
```

We can still see the pure value (23) in the output, but now we can also see that it is a `Kyo`. This will work similarly for other unboxed types like `Maybe` and `Result` (see below).

Note that `Render` does not convert to string but to `Text`--an enriched `String` alternative provided and used internally by Kyo. Kyo methods for displaying strings all accept `Text` values (see `Console` and `Log`, below). Converting values using `Render` directly can be cumbersome, however, so Kyo also provides a string interpolator to construct properly formatted `Text`s automatically. To use this interpolater, prefix your interpolated strings with `t` instead of `s`.

```scala
import kyo.*

val a: Int < Any = 23

Console.printLine(t"Kyo effect: $a, Kyo maybe: ${Maybe(23)}")
// Output: Kyo effect: Kyo(23), Kyo maybe: Present(23)
```

We recommend using `txt` as the default string interpolator in Kyo applications for the best developer experience.

## Core Effects

Kyo's core effects act as the essential building blocks that power your application's various functionalities. Unlike other libraries that might require heavy boilerplate or specialized knowledge, Kyo's core effects are designed to be straightforward and flexible. These core effects not only simplify the management of side-effects, dependencies, and several other aspects but also allow for a modular approach to building maintainable systems.
Expand Down Expand Up @@ -492,6 +532,28 @@ val d: Int < Abort[Exception] =
Abort.catching(throw new Exception)
```

To handle a potentially aborting effect, you can use `Abort.run`. This will produce a `Result`, a high-performance Kyo type equivalent to `Either`:

```scala
import kyo.*

// The 'get' method "extracts" the value
// from an 'Either' (right projection)
val a: Int < Abort[String] =
Abort.get(Right(1))

// short-circuiting via 'Left'
val b: Int < Abort[String] =
Abort.get(Left("failed!"))

val aRes: Result[String, Int] < Any = Abort.run(a)
val bRes: Result[String, Int] < Any = Abort.run(b)

// Note we use a t-string since Result is an unboxed type
println(t"A: ${aRes.eval}, B: ${bRes.eval}")
// Output: A: Success(1), B: Fail(failed!)
```

> Note that the `Abort` effect has a type parameter and its methods can only be accessed if the type parameter is provided.
### IO: Side Effects
Expand Down Expand Up @@ -1544,6 +1606,8 @@ val f: Unit < (IO & Abort[IOException]) =
Console.let(Console.live)(e)
```

Note that `Console.printX` methods accept `Text` values. `Text` is a super-type of `String`, however, so you can just pass regular strings. You can also pass `Text` instances generated from the `txt` string interpolator ([see above](#displaying-kyo-types)).

### Clock: Time Management and Scheduled Tasks

The `Clock` effect provides utilities for time-related operations, including getting the current time, creating stopwatches, and managing deadlines.
Expand Down Expand Up @@ -1728,6 +1792,8 @@ val d: Unit < IO =
Log.error("example", new Exception)
```

Note that like `Console`, `Log` methods accept `Text` values. This means they can also accept regular strings as well as outputs of `txt`-interpolation ([see above](#displaying-kyo-types)).

### Stat: Observability

`Stat` is a pluggable implementation that provides counters, histograms, gauges, and tracing. It uses Java's [service loading](https://docs.oracle.com/javase/8/docs/api/java/util/ServiceLoader.html) to locate exporters.
Expand Down Expand Up @@ -2716,6 +2782,21 @@ val result: String =
case Absent => "No value"
```

`Maybe`'s high performance is due to the fact that it is unboxed. Accordingly, we recommend using t-string interpolation when logging `Maybe`s:

```scala
import kyo.*

val maybe: Maybe[Maybe[Int]] = Maybe(Maybe(42))
val maybeNot: Maybe[Maybe[Int]] = Maybe(Maybe.Absent)

println(s"s-string nested maybes: $maybe, $maybeNot")
// Output: s-string nested maybes: 42, Absent

println(t"t-string nested maybes: $maybe, $maybeNot")
// Output: t-string nested maybes: Present(Present(42)), Present(Absent)
```

### Duration: Time Representation

`Duration` provides a convenient and efficient way to represent and manipulate time durations. It offers a wide range of operations and conversions, making it easy to work with time intervals in various units.
Expand Down Expand Up @@ -2825,6 +2906,21 @@ val q: Try[Int] = a.toTry

Under the hood, `Result` is defined as an opaque type that is a supertype of `Success[T]` and `Failure[T]`. Success[T] represents a successful result and is encoded as either the value itself (`T`) or a special SuccessFailure[`T`] case class. The `SuccessFailure[T]` case class is used to handle the rare case where a `Failure[T]` needs to be wrapped in a `Success[T]`. On the other hand, a failed `Result` is always represented by a `Failure[T]` case class, which contains the exception that caused the failure. This means that creating a `Failure[T]` does incur an allocation cost. Additionally, some methods on `Result`, such as `fold`, `map`, and `flatMap`, may allocate in certain cases due to the need to catch and handle exceptions.

Since `Result.Success` is unboxed, we recommend using t-string interpolation when logging `Result`s:

```scala
import kyo.*

val success: Result[String, Result[String, Int]] = Result.success(Result.success(42))
val failure: Result[String, Result[String, Int]] = Result.success(Result.fail("failure!"))

println(s"s-string nested results: $success, $failure")
// Output: s-string nested results: 42, Fail(failure!)

println(t"t-string nested results: $success, $failure")
// Output: t-string nested results: Success(Success(42)), Success(Fail(failure!))
```

### TypeMap: Type-Safe Heterogeneous Maps

`TypeMap` provides a type-safe heterogeneous map implementation, allowing you to store and retrieve values of different types using their types as keys. This is particularly useful for managing multiple types of data in a single structure with type safety.
Expand Down
8 changes: 4 additions & 4 deletions kyo-core/shared/src/main/scala/kyo/Console.scala
Original file line number Diff line number Diff line change
Expand Up @@ -19,28 +19,28 @@ final case class Console(unsafe: Console.Unsafe):
* @param s
* The string to print.
*/
def print(s: String)(using Frame): Unit < (IO & Abort[IOException]) = IO.Unsafe(Abort.get(unsafe.print(s)))
def print(s: Text)(using Frame): Unit < (IO & Abort[IOException]) = IO.Unsafe(Abort.get(unsafe.print(s.show)))

/** Prints a string to the console's error stream without a newline.
*
* @param s
* The string to print to the error stream.
*/
def printErr(s: String)(using Frame): Unit < (IO & Abort[IOException]) = IO.Unsafe(Abort.get(unsafe.printErr(s)))
def printErr(s: Text)(using Frame): Unit < (IO & Abort[IOException]) = IO.Unsafe(Abort.get(unsafe.printErr(s.show)))

/** Prints a string to the console followed by a newline.
*
* @param s
* The string to print.
*/
def println(s: String)(using Frame): Unit < (IO & Abort[IOException]) = IO.Unsafe(Abort.get(unsafe.printLine(s)))
def println(s: Text)(using Frame): Unit < (IO & Abort[IOException]) = IO.Unsafe(Abort.get(unsafe.printLine(s.show)))

/** Prints a string to the console's error stream followed by a newline.
*
* @param s
* The string to print to the error stream.
*/
def printLineErr(s: String)(using Frame): Unit < (IO & Abort[IOException]) = IO.Unsafe(Abort.get(unsafe.printLineErr(s)))
def printLineErr(s: Text)(using Frame): Unit < (IO & Abort[IOException]) = IO.Unsafe(Abort.get(unsafe.printLineErr(s.show)))

/** Flushes the console output streams.
*
Expand Down
24 changes: 13 additions & 11 deletions kyo-core/shared/src/main/scala/kyo/Log.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,19 @@ package kyo
import kyo.internal.LogPlatformSpecific

final case class Log(unsafe: Log.Unsafe):
def level: Log.Level = unsafe.level
inline def trace(inline msg: => String)(using inline frame: Frame): Unit < IO = IO.Unsafe(unsafe.trace(msg))
inline def trace(inline msg: => String, inline t: => Throwable)(using inline frame: Frame): Unit < IO = IO.Unsafe(unsafe.trace(msg, t))
inline def debug(inline msg: => String)(using inline frame: Frame): Unit < IO = IO.Unsafe(unsafe.debug(msg))
inline def debug(inline msg: => String, inline t: => Throwable)(using inline frame: Frame): Unit < IO = IO.Unsafe(unsafe.debug(msg, t))
inline def info(inline msg: => String)(using inline frame: Frame): Unit < IO = IO.Unsafe(unsafe.info(msg))
inline def info(inline msg: => String, inline t: => Throwable)(using inline frame: Frame): Unit < IO = IO.Unsafe(unsafe.info(msg, t))
inline def warn(inline msg: => String)(using inline frame: Frame): Unit < IO = IO.Unsafe(unsafe.warn(msg))
inline def warn(inline msg: => String, t: => Throwable)(using inline frame: Frame): Unit < IO = IO.Unsafe(unsafe.warn(msg, t))
inline def error(inline msg: => String)(using inline frame: Frame): Unit < IO = IO.Unsafe(unsafe.error(msg))
inline def error(inline msg: => String, t: => Throwable)(using inline frame: Frame): Unit < IO = IO.Unsafe(unsafe.error(msg, t))
def level: Log.Level = unsafe.level
inline def trace(inline msg: => Text)(using inline frame: Frame): Unit < IO = IO.Unsafe(unsafe.trace(msg.show))
inline def trace(inline msg: => Text, inline t: => Throwable)(using inline frame: Frame): Unit < IO =
IO.Unsafe(unsafe.trace(msg.show, t))
inline def debug(inline msg: => Text)(using inline frame: Frame): Unit < IO = IO.Unsafe(unsafe.debug(msg.show))
inline def debug(inline msg: => Text, inline t: => Throwable)(using inline frame: Frame): Unit < IO =
IO.Unsafe(unsafe.debug(msg.show, t))
inline def info(inline msg: => Text)(using inline frame: Frame): Unit < IO = IO.Unsafe(unsafe.info(msg.show))
inline def info(inline msg: => Text, inline t: => Throwable)(using inline frame: Frame): Unit < IO = IO.Unsafe(unsafe.info(msg.show, t))
inline def warn(inline msg: => Text)(using inline frame: Frame): Unit < IO = IO.Unsafe(unsafe.warn(msg.show))
inline def warn(inline msg: => Text, t: => Throwable)(using inline frame: Frame): Unit < IO = IO.Unsafe(unsafe.warn(msg.show, t))
inline def error(inline msg: => Text)(using inline frame: Frame): Unit < IO = IO.Unsafe(unsafe.error(msg.show))
inline def error(inline msg: => Text, t: => Throwable)(using inline frame: Frame): Unit < IO = IO.Unsafe(unsafe.error(msg.show, t))
end Log

/** Logging utility object for Kyo applications. */
Expand Down
4 changes: 2 additions & 2 deletions kyo-core/shared/src/test/scala/kyo/ConsoleTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ class ConsoleTest extends Test:
val obj = Obj("a")
val pprintObj = pprint.apply(obj).toString

"readln" in run {
"readLine" in run {
Console.withIn(List("readln")) {
Console.readLine.map { result =>
assert(result == "readln")
}
}
}
"print" in run {
"print string" in run {
Console.withOut(Console.print("print")).map { (out, _) =>
assert(out.stdOut == "print")
}
Expand Down
14 changes: 9 additions & 5 deletions kyo-data/shared/src/main/scala/kyo/Maybe.scala
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ object Maybe:
inline given [A: Flat]: Flat[Maybe[A]] = Flat.unsafe.bypass
given [A]: Conversion[Maybe[A], IterableOnce[A]] = _.iterator

given [A, MaybeA <: Maybe[A]](using ra: Render[A]): Render[MaybeA] with
given CanEqual[Absent, MaybeA] = CanEqual.derived
def asText(value: MaybeA): String = (value: Maybe[A]) match
case Present(a) => s"Present(${ra.asText(a)})"
case Absent => "Absent"
case _ => throw IllegalStateException()
end given

/** Creates a Maybe instance from a value.
*
* @param v
Expand Down Expand Up @@ -71,9 +79,7 @@ object Maybe:

/** Represents a defined value in a Maybe. */
opaque type Present[+A] = A | PresentAbsent

object Present:

/** Creates a Present instance.
*
* @param v
Expand Down Expand Up @@ -411,9 +417,7 @@ object Maybe:
inline def toResult[E](inline ifEmpty: => Result[E, A]): Result[E, A] =
if isEmpty then ifEmpty else Result.success(get)

def show: String =
if isEmpty then "Absent"
else s"Present(${get})"
def show(using r: Render[Maybe[A]]): String = r.asString(self)

end extension

Expand Down
100 changes: 100 additions & 0 deletions kyo-data/shared/src/main/scala/kyo/Render.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package kyo

import kyo.Schedule.done
import scala.language.implicitConversions

/** Provides Text representation of a type. Needed for customizing how to display opaque types as alternative to toString
*/
abstract class Render[A]:
def asText(value: A): Text
final def asString(value: A): String = asText(value).show

sealed trait LowPriorityRenders:
given [A]: Render[A] with
def asText(value: A): Text = value.toString

object Render extends LowPriorityRenders:
inline def apply[A](using r: Render[A]): Render[A] = r

def asText[A](value: A)(using r: Render[A]): Text = r.asText(value)

import scala.compiletime.*

private inline def sumRender[A, M <: scala.deriving.Mirror.ProductOf[A]](label: String, mir: M): Render[A] =
val shows = summonAll[Tuple.Map[mir.MirroredElemTypes, Render]]
new Render[A]:
def asText(value: A): String =
val builder = java.lang.StringBuilder()
builder.append(label)
builder.append("(")
val valIter = value.asInstanceOf[Product].productIterator
val showIter: Iterator[Render[Any]] = shows.productIterator.asInstanceOf
if valIter.hasNext then
builder.append(showIter.next().asText(valIter.next()))
()
while valIter.hasNext do
builder.append(",")
builder.append(showIter.next().asText(valIter.next()))
()
end while
builder.append(")")
builder.toString()
end asText
end new
end sumRender

inline given [A](using mir: scala.deriving.Mirror.Of[A]): Render[A] = inline mir match
case sumMir: scala.deriving.Mirror.SumOf[?] =>
val shows = summonAll[Tuple.Map[sumMir.MirroredElemTypes, Render]]
new Render[A]:
def asText(value: A): Text =
val caseIndex = sumMir.ordinal(value)
val showInstance: Render[Any] = shows.productElement(caseIndex).asInstanceOf
showInstance.asText(value)
end asText
end new
case singMir: scala.deriving.Mirror.Singleton =>
val label: String = constValue[singMir.MirroredLabel]
new Render[A]:
def asText(value: A): Text = label
case prodMir: scala.deriving.Mirror.ProductOf[?] => inline erasedValue[A] match
case _: Tuple =>
inline erasedValue[prodMir.MirroredElemTypes] match
case _: EmptyTuple =>
new Render[A]:
def asText(value: A): Text = "()"
case _ =>
sumRender[A, prodMir.type]("", prodMir)
case _ =>
val label: String = constValue[prodMir.MirroredLabel]
inline erasedValue[prodMir.MirroredElemTypes] match
case _: EmptyTuple =>
new Render[A]:
def asText(value: A): Text = label + "()"
case _ =>
sumRender[A, prodMir.type](label, prodMir)
end match

end Render

sealed trait Rendered:
private[kyo] def textValue: Text

object Rendered:
given [A](using r: Render[A]): Conversion[A, Rendered] with
def apply(a: A): Rendered =
new Rendered:
private[kyo] def textValue: Text = r.asText(a)
end given
end Rendered

extension (sc: StringContext)
def t(args: Rendered*): Text =
StringContext.checkLengths(args, sc.parts)
val pi = sc.parts.iterator
val ai = args.iterator
var text: Text = pi.next()
while ai.hasNext do
text = text + ai.next.textValue
text = text + StringContext.processEscapes(pi.next())
text
13 changes: 8 additions & 5 deletions kyo-data/shared/src/main/scala/kyo/Result.scala
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ object Result:
inline given [E, A: Flat]: Flat[Result[E, A]] = Flat.unsafe.bypass
inline given [E, A]: CanEqual[Result[E, A], Panic] = CanEqual.derived

given [E, A, ResultEA <: Result[E, A]](using re: Render[E], ra: Render[A]): Render[ResultEA] with
def asText(value: ResultEA): String = value match
case Success(a) => s"Success(${ra.asText(a.asInstanceOf[A])})"
case f: Fail[?] => s"Fail(${re.asText(f.error.asInstanceOf[E])})"
case other => other.toString()
end given

/** Creates a Result from an expression that might throw an exception.
*
* @param expr
Expand Down Expand Up @@ -632,11 +639,7 @@ object Result:
* @return
* A string describing the Result's state and value
*/
def show: String =
self match
case Panic(ex) => s"Panic($ex)"
case Fail(ex) => s"Fail($ex)"
case v => s"Success($v)"
def show(using r: Render[Result[E, A]]): String = r.asString(self)

end extension

Expand Down
Loading

0 comments on commit 13ed174

Please sign in to comment.