Skip to content

Introduce another version of Signals that has no uninitialized field. #11238

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jan 29, 2021

Conversation

sjrd
Copy link
Member

@sjrd sjrd commented Jan 28, 2021

In this case, Signals1.scala was even worse than what's in lampelf/moocs, because it actually relied on the default value assigned by the = _, i.e., null, in the sense that it actually read that value and compared it with != to the first computed value.

This is the diff compared to Signals1.scala:

--- tests/run/Signals1.scala    2020-12-09 11:10:53.930426300 +0100
+++ tests/run/Signals2.scala    2021-01-28 10:07:31.160714800 +0100
@@ -8,10 +8,10 @@
   object Signal:

     abstract class AbstractSignal[+T] extends Signal[T]:
-      private var currentValue: T = _
       private var observers: Set[Caller] = Set()
+      private var currentValue: T = eval(this)

-      protected def eval: Caller => T
+      protected def eval(caller: Caller): T

       protected def computeValue(): Unit =
         val newValue = eval(this)
@@ -30,21 +30,20 @@

     def apply[T](expr: Caller ?=> T): Signal[T] =
       new AbstractSignal[T]:
-        protected val eval = expr(using _)
+        protected def eval(caller: Caller) = expr(using caller)
         computeValue()

-    class Var[T](expr: Caller ?=> T) extends AbstractSignal[T]:
-      protected var eval: Caller => T = expr(using _)
-      computeValue()
+    class Var[T](private var expr: Caller ?=> T) extends AbstractSignal[T]:
+      protected def eval(caller: Caller) = expr(using caller)

       def update(expr: Caller ?=> T): Unit =
-        eval = expr(using _)
+        this.expr = expr
         computeValue()
     end Var

     opaque type Caller = AbstractSignal[?]
-    given noCaller: Caller = new AbstractSignal[Nothing]:
-      override def eval = ???
+    given noCaller: Caller = new AbstractSignal[Unit]:
+      protected def eval(caller: Caller) = ()
       override def computeValue() = ()

   end Signal

@sjrd sjrd requested review from julienrf and odersky and removed request for julienrf January 28, 2021 09:15
Copy link
Contributor

@odersky odersky left a comment

Choose a reason for hiding this comment

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

I think that's a good version, but it would be nice if noCaller was Signal[Nothing] because that is the right semantics for it.

What changes are necessary to achieve this?

@sjrd
Copy link
Member Author

sjrd commented Jan 28, 2021

To do that, NoSignal must not have a field currentValue: T at all. Note that before, this was already sketchy at best, since we relied on a var of type Nothing to exist, conceptually, and relied on the fact that a = _ for a type Nothing does not immediately throw an exception.

So if you want NoSignal to be a Signal[Nothing], we have to decouple currentValue from what a Caller is. That means we have to introduce a new class or trait in the hierarchy, for the computeValue() method alone. It might as well be Caller itself turned into a trait, and even without a type parameter or an apply() method at all. This works fine except for the assertion for cycle detection, which requires to access caller.observers. That's not possible if Caller is a supertrait that does not necessarily have an observers field. So we have to introduce another indirection for that. In addition, the methods of Caller need to be private[Signal] instead of protected. I am not sure whether this is thaught to students at that point.

Anyway, this is what we need:

import annotation.unchecked._
package frp:

  trait Signal[+T]:
    def apply()(using caller: Signal.Caller): T

  object Signal:

    trait Caller:
      private[Signal] def computeValue(): Unit
      private[Signal] def hasObserver(observer: Caller): Boolean

    abstract class AbstractSignal[+T] extends Signal[T] with Caller:
      private var observers: Set[Caller] = Set()
      private var currentValue: T = eval(this)

      protected def eval(caller: Caller): T

      private[Signal] def computeValue(): Unit =
        val newValue = eval(this)
        val observeChange = observers.nonEmpty && newValue != currentValue
        currentValue = newValue
        if observeChange then
          val obs = observers
          observers = Set()
          obs.foreach(_.computeValue())

      private[Signal] def hasObserver(observer: Caller): Boolean =
        observers.contains(observer)

      def apply()(using caller: Caller): T =
        observers += caller
        assert(!caller.hasObserver(this), "cyclic signal definition")
        currentValue
    end AbstractSignal

    def apply[T](expr: Caller ?=> T): Signal[T] =
      new AbstractSignal[T]:
        protected def eval(caller: Caller) = expr(using caller)
        computeValue()

    class Var[T](private var expr: Caller ?=> T) extends AbstractSignal[T]:
      protected def eval(caller: Caller) = expr(using caller)

      def update(expr: Caller ?=> T): Unit =
        this.expr = expr
        computeValue()
    end Var

    given noCaller: Caller = new Caller:
      private[Signal] def computeValue(): Unit = ()
      private[Signal] def hasObserver(observer: Caller): Boolean = false

  end Signal
end frp

This is less simple, but it is also a better design. With that, noCaller is not even a Signal anymore. Even better than being a Signal[Nothing]. It doesn't matter very much, though, since Caller is an opaque type in the version where it is a Signal, so users of Signal don't see it at all.

Regardless, in the context of the moocs, I believe the version where it is a Signal[Unit] is better, only because it introduces less concepts. In a real application, I would definitely have a separate Caller abstraction.

@@ -0,0 +1,83 @@

import annotation.unchecked._
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
import annotation.unchecked._
// An alternative to Signals1 that does not rely on an uninitialized variable
import annotation.unchecked._

Copy link
Member Author

Choose a reason for hiding this comment

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

Added.

@odersky
Copy link
Contributor

odersky commented Jan 29, 2021

I agree this is an interesting alternative.

@sjrd sjrd force-pushed the no-uninitialized-fields-in-signal branch from 533ed08 to b9ad750 Compare January 29, 2021 07:56
@sjrd sjrd merged commit 081fbc6 into scala:master Jan 29, 2021
@sjrd sjrd deleted the no-uninitialized-fields-in-signal branch January 29, 2021 08:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants