본문 바로가기

Posts

[FP] Covariance, Contravariance

Intro

At its core, variance is about how the subtype relationship of a type parameter influences the subtype relationship of a generic type itself. It addresses the question: when a subtype relationship is established for a type parameter (e.g., Dog <: Animal), how does this relationship translate to the generic type when that type parameter is injected into it?

The answer largely depends on how the type parameter is used within the generic type – whether it's primarily for input or output.

Why Variance Matters in Functional Programming

While variance (covariance, contravariance, and invariance) aren't concepts exclusive to functional programming (FP), they appear particularly frequently and prominently in it. This is largely because FP heavily relies on type class-based abstractions like Functor, Contravariant, and Monad. These abstractions often operate with type parameters, and understanding what variance these parameters can have is crucial.

Consider Scala's Function1 trait, which represents a function that takes one argument:

trait Function1[-A, +B] {
  def apply(a: A): B
}

This structure is a core abstraction in functional programming. Because FP often deals with composing and transforming functions and data within these type-parameterized structures, variance becomes much more apparent and essential for ensuring type safety and flexible code composition.

Understanding the Subtype Concept

A subtype in programming means that a specific type can incorporate or extend all the functionalities of another type. In essence, it adheres to the principle that a child type (subtype) can safely be used anywhere a parent type (supertype) is expected. This is also known as the Liskov Substitution Principle.

For example, imagine you have a type Animal and a type Dog that inherits from Animal. In this case, Dog is a subtype of Animal. Any function designed to accept an Animal object should seamlessly accept a Dog object without causing issues.

Variance: Covariance, Contravariance, and Invariance

Let's dive into the specifics of each variance type:

  • Covariance: The subtype relationship of the type argument is preserved in the same direction for the generic type.
    • If B is a subtype of A, then List[B] is a subtype of List[A].
    • This typically appears in creation contexts (e.g., when taking elements out of a List).
  • Contravariance: The subtype relationship of the type argument is preserved in the opposite direction for the generic type.
    • If B is a subtype of A, then Encoder[A] is a subtype of Encoder[B].
    • This is because an Encoder that can handle A can certainly handle B (which is a more specific type of A).
    • This typically appears in input contexts (e.g., when putting elements into an Encoder).
  • Invariance: The subtype relationship of the type argument has no effect on the subtype relationship of the generic type.
    • If B is a subtype of A, Array[B] is neither a subtype nor a supertype of Array[A]. (While Scala's Array is invariant, Java's Array is covariant, which can lead to runtime errors and should be handled with care.)
    • This usually occurs in contexts where both reading and writing are possible. Invariance is often the default to ensure type safety.

Encoder and Decoder Examples (Scala)

Encoder and Decoder are common examples used to illustrate interfaces that transform one type into another. Let's consider JSON serialization/deserialization.

Decoder (Covariant)

A Decoder is responsible for transforming an input (e.g., a JSON string) into an object of a specific type. You can think of it as producing data. In Scala, we use the + symbol to denote covariance.

trait Decoder[+A] { // The '+' symbol indicates Covariance.
  def decode(data: String): A
}

class Animal
class Dog extends Animal

object DecoderExample extends App {
  // Decoder[Dog] is a subtype of Decoder[Animal].
  val dogDecoder: Decoder[Dog] = new Decoder[Dog] {
    override def decode(data: String): Dog = {
      println(s"Decoding Dog from $data")
      new Dog()
    }
  }

  def processAnimal[T <: Animal](decoder: Decoder[T]): T = {
    val animal = decoder.decode("someJson")
    println(s"Processed an animal: $animal")
    animal
  }

  // Thanks to Covariance, we can use a Decoder[Dog] where a Decoder[Animal] is expected.
  val processedDog: Animal = processAnimal(dogDecoder)
  println(s"Resulting animal is a Dog: ${processedDog.isInstanceOf[Dog]}")

  // Another example: Decoder[String]
  val stringDecoder: Decoder[String] = new Decoder[String] {
    override def decode(data: String): String = {
      println(s"Decoding String: $data")
      data
    }
  }

  // We can pass a Decoder[String] to a function that accepts a Decoder[Any].
  def processAnything(decoder: Decoder[Any]): Any = decoder.decode("Hello, Scala!")
  val decodedAny = processAnything(stringDecoder)
  println(s"Decoded Any: $decodedAny, type: ${decodedAny.getClass.getSimpleName}")
}
Decoding Person from: name: Alice
Mapping Person to Employee
Decoded employee: Employee(E123,Person(Alice))

In the example above, passing a Decoder[Dog] type dogDecoder to the processAnimal function, which accepts a Decoder[Animal], is possible because Decoder has Covariance (indicated by the +A keyword).

Encoder (Contravariant)

An Encoder is responsible for transforming an object of a specific type into another form (e.g., a JSON string). It can be seen as taking input to process. In Scala, we use the symbol to denote contravariance.

trait Encoder[-A] { // The '-' symbol indicates Contravariance.
  def encode(value: A): String
}

class Animal
class Dog extends Animal

object EncoderExample extends App {
  // Encoder[Animal] is a subtype of Encoder[Dog].
  val animalEncoder: Encoder[Animal] = new Encoder[Animal] {
    override def encode(value: Animal): String = {
      println(s"Encoding Animal: $value to JSON")
      "{}" // Assuming a real JSON string
    }
  }

  def processDog[T <: Dog](encoder: Encoder[T], dog: T): String = {
    val json = encoder.encode(dog)
    println(s"Encoded dog to: $json")
    json
  }

  val myDog = new Dog()

  // Thanks to Contravariance, we can use an Encoder[Animal] where an Encoder[Dog] is expected.
  val encodedDogJson = processDog(animalEncoder, myDog)
  println(s"Encoded Dog JSON: $encodedDogJson")

  // Another example: Encoder[Any]
  val anyEncoder: Encoder[Any] = new Encoder[Any] {
    override def encode(value: Any): String = {
      println(s"Encoding Any: $value")
      value.toString
    }
  }

  // We can pass an Encoder[Any] to a function that accepts an Encoder[String].
  def encodeString(encoder: Encoder[String], s: String): String = encoder.encode(s)
  val encodedString = encodeString(anyEncoder, "Hello, Contravariance!")
  println(s"Encoded String: $encodedString")
}
Encoding Animal: Dog@23d90921 to JSON
Encoded dog to: {}
Encoded Dog JSON: {}
Encoding Any: Hello, Contravariance!
Encoded String: Hello, Contravariance!

In the example above, passing an Encoder[Animal] type animalEncoder to the processDog function, which accepts an Encoder[Dog], is possible because Encoder has Contravariance (indicated by the A keyword). An encoder that can encode an Animal can, of course, also encode a Dog (a subtype of Animal), thus ensuring type safety.

Understanding map and contramap

In functional programming, map and contramap are core operations of Functors and Contravariant Functors, respectively. They are used to "compose" new transformations onto existing type conversion logic.

  • map (related to Covariance)
    • Used to transform a value of type F[A] into a value of type F[B].
    • map takes a function A => B as an argument.
    • This transformation applies the function to the A value inside F[A], producing F[B]. map preserves covariance, meaning the "direction" of the type relationship remains the same.
    • Example: When transforming List[A] to List[B], you use map. The function A => B is applied to each element in the list.
  • contramap (related to Contravariance)
    • Used to transform a value of type F[B] into a value of type F[A].
    • contramap takes a function A => B as an argument, but this function is applied in the opposite direction of F's input type.
    • That is, if F "consumes" B, to make it "consume" A, you first need to transform A to B.
    • contramap preserves contravariance, meaning the "direction" of the type relationship is reversed.
    • Example: When transforming Encoder[B] to Encoder[A], you use contramap.
    • It takes a function A => B as an argument, enabling Encoder[B] to accept A by first transforming A into B before encoding.

map and contramap Implementation Examples (Encoder, Decoder) (Scala)

Decoder (Covariant - using map)

A Decoder can have a map function. When you have a Decoder[A], you can use a function that transforms A to B to create a Decoder[B].

trait Decoder[+A] {
  def decode(data: String): A

  // map function: Transforms Decoder[A] to Decoder[B]
  def map[B](f: A => B): Decoder[B] = new Decoder[B] {
    override def decode(data: String): B = {
      f(Decoder.this.decode(data)) // First decode to A, then transform to B
    }
  }
}

case class Person(name: String)
case class Employee(id: String, person: Person)

object DecoderMapExample extends App {
  // Assume we have a Decoder[Person] that decodes a String into a Person.
  val personDecoder: Decoder[Person] = new Decoder[Person] {
    override def decode(data: String): Person = {
      println(s"Decoding Person from: $data")
      // Real-world logic would involve JSON parsing
      Person(data.stripPrefix("name:").trim)
    }
  }

  // Now we want to obtain a Decoder[Employee].
  // Instead of directly decoding String to Employee,
  // it's more natural to decode String to Person, and then transform Person to Employee.
  val employeeDecoder: Decoder[Employee] = personDecoder.map { person =>
    // Logic to transform a Person object into an Employee object (a Person => Employee function)
    println("Mapping Person to Employee")
    Employee("E123", person) // Example ID
  }

  val employee = employeeDecoder.decode("name: Alice")
  println(s"Decoded employee: $employee")
}

Through personDecoder.map, we passed a lambda function that transforms Person to Employee, thereby converting Decoder[Person] to Decoder[Employee]. The essence of map is to provide a Person => Employee function when you have a String => Person and want a String => Employee. This operation is possible because Decoder is Covariant.

Encoder (Contravariant - using contramap)

An Encoder can have a contramap function. When you have an Encoder[B], you can use a function that transforms A to B to create an Encoder[A].

trait Encoder[-A] {
  def encode(value: A): String

  // contramap function: Transforms Encoder[B] to Encoder[A] (reverse direction)
  def contramap[B](f: B => A): Encoder[B] = new Encoder[B] { // The argument is a function that transforms B to A
    override def encode(value: B): String = {
      Encoder.this.encode(f(value)) // First transform B to A, then encode A
    }
  }
}

case class EmailAddress(value: String)
case class EmailSendRequest(to: EmailAddress, subject: String, body: String)

object EncoderContramapExample extends App {
  // Assume we have an Encoder[EmailAddress] that encodes an EmailAddress to a String.
  val emailAddressEncoder: Encoder[EmailAddress] = new Encoder[EmailAddress] {
    override def encode(value: EmailAddress): String = {
      println(s"Encoding EmailAddress: ${value.value}")
      s"""${value.value}""" // Wrap in JSON string
    }
  }

  // Now we want to obtain an Encoder[EmailSendRequest] that encodes an EmailSendRequest to a String.
  // Instead of directly encoding EmailSendRequest to String,
  // it's more natural to transform EmailSendRequest to EmailAddress, and then encode the EmailAddress.
  val emailSendRequestEncoder: Encoder[EmailSendRequest] = emailAddressEncoder.contramap { request =>
   // Logic to transform an EmailSendRequest object into an EmailAddress object (an EmailSendRequest => EmailAddress function)
    println(s"Contramapping EmailSendRequest to EmailAddress for encoding 'to' field")
    request.to // Extract only the recipient email address from the request
  }

  val request = EmailSendRequest(EmailAddress("test@example.com"), "Hello", "This is a test.")
  val json = emailSendRequestEncoder.encode(request)
  println(s"Encoded EmailSendRequest (only 'to' field) to JSON: $json")
}

Through emailAddressEncoder.contramap, we passed a lambda function that transforms EmailSendRequest to EmailAddress, thereby converting Encoder[EmailAddress] to Encoder[EmailSendRequest]. The essence of contramap is to provide an EmailSendRequest => EmailAddress function when you have an EmailAddress => String and want an EmailSendRequest => String. This operation is possible because Encoder is Contravariant.

Scala's Function1 trait

  • Consider Scala's Function1 trait again, which represents a function that takes one argument:
trait Function1[-A, +B] {
  def apply(a: A): B
}

Here's why variance plays a key role:

  • The output type B is covariant (+B): This means the function can return a more specific type than specified.
    • If you have a function Int => Dog, you can use it where Int => Animal is expected.
    • It's safe because if it promises an Animal, and it gives you a Dog (a more specific Animal), that's perfectly fine.
    • So, Function1[Int, Dog] is a subtype of Function1[Int, Animal]. The subtype relationship for the output type goes in the same direction.
  • The input type A is contravariant (A): This means a function that expects a more specific type for its input can be replaced by a function that expects a more general type.
    • If you have a function Any => Int, it can be used where String => Int is expected.
    • It's safe because if it can process any type (Any), it can certainly process a String (which is a specific Any).
    • So, Function1[Any, Int] is a subtype of Function1[String, Int].
    • The subtype relationship for the input type goes in the opposite direction.

Conclusion

  • Variance in type parameters is a powerful concept that enables greater flexibility and type safety in object-oriented and functional programming paradigms.
  • While the core ideas of covariance, contravariance, and invariance exist independently, they are especially prominent in functional programming due to its heavy reliance on generic abstractions and the composition of functions.
  • Understanding how input and output types behave with respect to subtyping, as clearly demonstrated by Scala's Function1 trait, is fundamental for designing robust and extensible functional code.
  • By embracing these principles, developers can write more adaptable and predictable programs, making type-driven development a more seamless experience.