IdentityBoth
IdentityBoth[F]
describes an associative way to combine two values F[A]
and F[B]
into a value F[(A, B)]
that also has an identity element of type F[Any]
.
Its signature is:
trait AssociativeBoth[F[_]] {
def both[A, B](fa: => F[A], fb: => F[B]): F[(A, B)]
}
trait IdentityBoth[F[_]] extends AssociativeBoth[F] {
def any: F[Any]
}
The any
value must be an identity element with respect to the both
operator, so that after removing unnecessary tuples the following property holds:
fa <*> identity === fa
identity <*> fa === fa
This is the same as the laws for the Identity
functional abstraction for concrete types except lifted into the context of parameterized types.
To be an identity element, running the any
value must not do anything so we can always compose it as many times as we want with the both
operator without changing the result.
As with the other abstractions for parameterized types we have looked at ZIO
provides a good initial example.
The any
value for ZIO
is unit
, the workflow that does not do anything, always succeeds, and produces no useful information. We can do nothing before or after another ZIO
workflow as many times as we want and we will always get a workflow that does the same thing.
import zio._
import zio.console._
import java.io.IOException
val helloUnit: ZIO[Console, IOException, (Unit, Unit)] =
console.putStrLn("Hello") <*> ZIO.unit
// helloUnit: ZIO[Console, IOException, (Unit, Unit)] = zio.ZIO$FlatMap@3440ad31
val unitHello: ZIO[Console, IOException, (Unit, Unit)] =
ZIO.unit <*> console.putStrLn("Hello")
// unitHello: ZIO[Console, IOException, (Unit, Unit)] = zio.ZIO$FlatMap@75d3d5ed
These programs are identical because ZIO.unit
does not do anything at all and always succeeds.
Similarly, the any
with respect to data types such as Either
and Option
that model failure is a successful value that contains no useful information.
val anyEither: Either[Nothing, Any] =
Right(())
// anyEither: Either[Nothing, Any] = Right(value = ())
val anyOption: Option[Any] =
Some(())
// anyOption: Option[Any] = Some(value = ())
Since the both
operator for these data types corresponds to failing on the first error or returning a success with all the results, the any
value can never change the result. If any
is combined with a failed value the result will be that failure, and if it is combined with a successful value the result will be that success.
For collection types the any
value is a collection with a single value containing no useful information.
val anyList: List[Any] =
List(())
// anyList: List[Any] = List(())
We might be tempted to think that an empty collection would be the identity value but we can see this is not the case because the both
operator corresponds to the Cartesian product of two collections and the product of a collection with the empty collection is the empty collection, not the original collection.
If we think of a collection as representing a set of possible states then we can think of this as the state that occurs with certainty. Or if we think of the both
operator as being the product of two collections the identity element for multiplication is one, not zero.
For a parser the identity element would be a parser that always succeeds with no useful information and does not change the parse state.
We can also define identity values with respect to contravariant types.
For example, consider the Predicate
data type.
trait Predicate[-A] {
def run(a: A): Boolean
}
We could define an IdentityBoth
instance for it like this:
import zio.prelude._
object Predicate {
implicit val PredicateIdentityBoth: IdentityBoth[Predicate] =
new IdentityBoth[Predicate] {
val any: Predicate[Any] =
new Predicate[Any] {
def run(a: Any) =
true
}
def both[A, B](left: => Predicate[A], right: => Predicate[B]): Predicate[(A, B)] =
new Predicate[(A, B)] {
def run(tuple: (A, B)): Boolean =
left.run(tuple._1) && right.run(tuple._2)
}
}
}
The both
operator for Predicate
combines two predicates to return a new predicate that is true if both of the original predicates are true. So we can always combine any predicate with the predicate that is always true without changing the result.
This again shows the value of separating abstractions for defining how parameterized types can be combined from abstractions for describing their variance. Otherwise we would face a proliferation of abstractions to describe the product of these various combinations of properties of the combining operation and the variance of the data type, as other functional programming libraries have experienced.
When a data type has both a IdentityBoth
and a Covariant
instance we can define a particularly useful operator for it called succeed
.
def succeed[F[+_]: IdentityBoth : Covariant, A](a: => A): F[A] =
IdentityBoth[F].any.map(_ => a)
This says that if a data type is also covariant we can always "lift" any value into the data type by starting with the identity value and using map
to transform the output type to the specified value.
This is very useful for working with parameterized data types in general because it allows us to take ordinary values and use them in the context of our parameterized type.
For example we can use the succeed
operator on ZIO
to wrap any arbitrary block of Scala code in a ZIO
effect.
val helloScala: ZIO[Any, Nothing, Unit] =
ZIO.succeed(println("Hello, Scala!"))
// helloScala: ZIO[Any, Nothing, Unit] = zio.ZIO$EffectTotal@5b4f6607
This is also quite useful in ZIO Prelude in particular because many operators are only defined for data types that have an IdentityBoth
instance, such as the forEach
operator on the ForEach
abstraction.
As with concrete data types, it is quite useful to have an identity element with respect to the combining operator for parameterized types.
Most of the time for working with existing data types from ZIO or the Scala standard library it will be less important directly because operators for that data type will already be defined for you.
However, it can still be helpful to think about what the identity value is with respect to a given operation. For example, you may not have thought of a Right
with no useful information as being an identity with respect to combining Either
values.
When defining your own parameterized data types, it can be helpful to think about whether an identity element exists with respect to combining values of your data type. If not, could it be refactored to have such an identity value, or does thinking about that tell you something about why it makes sense that your data type does not have one?
Finally, if you are writing generic code in terms of the functional abstractions in ZIO Prelude this is likely to be another important abstraction. In particular the ability to lift a value into the parameterized type described by the combination of the CommutativeBoth
and Covariant
abstractions is often important.