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 ofA
, thenList[B]
is a subtype ofList[A]
. - This typically appears in creation contexts (e.g., when taking elements out of a
List
).
- If
- Contravariance: The subtype relationship of the type argument is preserved in the opposite direction for the generic type.
- If
B
is a subtype ofA
, thenEncoder[A]
is a subtype ofEncoder[B]
. - This is because an
Encoder
that can handleA
can certainly handleB
(which is a more specific type ofA
). - This typically appears in input contexts (e.g., when putting elements into an
Encoder
).
- If
- Invariance: The subtype relationship of the type argument has no effect on the subtype relationship of the generic type.
- If
B
is a subtype ofA
,Array[B]
is neither a subtype nor a supertype ofArray[A]
. (While Scala'sArray
is invariant, Java'sArray
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.
- If
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 typeF[B]
. map
takes a functionA => B
as an argument.- This transformation applies the function to the
A
value insideF[A]
, producingF[B]
.map
preserves covariance, meaning the "direction" of the type relationship remains the same. - Example: When transforming
List[A]
toList[B]
, you usemap
. The functionA => B
is applied to each element in the list.
- Used to transform a value of type
contramap
(related to Contravariance)- Used to transform a value of type
F[B]
into a value of typeF[A]
. contramap
takes a functionA => B
as an argument, but this function is applied in the opposite direction ofF
's input type.- That is, if
F
"consumes"B
, to make it "consume"A
, you first need to transformA
toB
. contramap
preserves contravariance, meaning the "direction" of the type relationship is reversed.- Example: When transforming
Encoder[B]
toEncoder[A]
, you usecontramap
. - It takes a function
A => B
as an argument, enablingEncoder[B]
to acceptA
by first transformingA
intoB
before encoding.
- Used to transform a value of type
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 whereInt => Animal
is expected. - It's safe because if it promises an
Animal
, and it gives you aDog
(a more specificAnimal
), that's perfectly fine. - So,
Function1[Int, Dog]
is a subtype ofFunction1[Int, Animal]
. The subtype relationship for the output type goes in the same direction.
- If you have a function
- 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 whereString => Int
is expected. - It's safe because if it can process any type (
Any
), it can certainly process aString
(which is a specificAny
). - So,
Function1[Any, Int]
is a subtype ofFunction1[String, Int]
. - The subtype relationship for the input type goes in the opposite direction.
- If you have a function
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.
'Posts' 카테고리의 다른 글
[FP] Database Optimization in Event Sourcing + FP Architecture (0) | 2025.06.23 |
---|---|
Scala Macro + Implicit's Power: Tackling Ambiguous Error Messages with "sourcecode” (0) | 2025.06.22 |
[FP] The Way I Understand Monads (0) | 2025.06.22 |
GC of python (0) | 2025.05.17 |
How does Python handle hash collisions? (0) | 2022.04.21 |