10 ms·
Understanding contravariance
- Myrmornis 8y agoAre there some good examples of problems for which these sorts of programming tools are particularly helpful, or yield a particularly elegant solution, or is their role more academic?
- jayd16 8y agoI've used covariance and contravariance in C# before. I can't say its particularly elegant because it has the code spell of being too exotic but the alternatives if we did not use the features would have been a lot worse. Simple C# doc page explaining the language feature in less academic and more brass tacks terms: https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/covariance-contravariance/ https://docs.microsoft.com/en-us/dotnet/csharp/programming-g...
- willtim 8y agoOf course C#, because of subtyping, also has contravariant type parameters (unlike Haskell). The variance phenomena shows up in many places.
- evincarofautumn 8y agoA kind of subtyping does show up in Haskell with higher-rank polymorphism: the System F “generic instance” subtyping rule. Namely, for types A and B, A is a subtype of B iff A is a “generic instance” of B, meaning A is at least as polymorphic as B. “Higher-rank polymorphism” sounds scarier than it is, too, by the way. It’s basically just a way to pass polymorphic functions as arguments. It’s what you really want any time you think you’re craving a “virtual template” in C++; the typical OOP solution in a language like C# is more convoluted, usually consisting of wrapping the function in a class with a generic method and invoking it through an interface. The intuition is that you can pass a more polymorphic function (like the identity function of type “∀a. a → a”) as an argument to a function expecting a less polymorphic one (like “Int → Int”) and it’ll just be specialised—but you clearly can’t do the reverse because “Int → Int” doesn’t accept all the types that “∀a. a → a” does (like “String → String”, “Char → Char”, “Bool → Bool”, &c.). However, the subtyping gets flipped every time you go to the left of a function arrow, because it’s contravariant. And you only notice this with higher-rank polymorphism because that’s the only place Haskell has a subtyping rule like this. My usual example, stolen from an SPJ paper, is: f ∷ ((∀a. [a] → [a]) → Int) → Int f x = x (drop 1) g ∷ ([Int] → [Int]) → Int g y = sum (y [1, 2, 3]) h ∷ (∀a. a → a) → Int h z = z 5 Or, in pseudo-imperative notation for those not familiar with Haskell: func f(x: (<T> (List<T> => List<T>)) => int): int { return x(list => list.drop(1)); } func g(y: List<int> => List<int>): int { return y([1, 2, 3]).sum(); } func h(z: <T> (T => T)): int { return z(5); } “f g” works because “([Int] → [Int]) → Int” (the type of g) is more polymorphic than “(∀a. [a] → [a]) → Int” (the type of x, the parameter of f), because “[Int] → [Int]” (the type of y, the parameter of g) is less polymorphic than “∀a. [a] → [a]” (the type of the parameter of x). f g f (\ y -> sum (y [1, 2, 3])) (\ y -> sum (y [1, 2, 3])) (drop 1) sum (drop 1 [1, 2, 3]) sum [2, 3] 5 But “f h” is a type error, because “(∀a. a → a) → Int” (the type of h) is less polymorphic than “(∀a. [a] → [a]) → Int” (the type of x), because “∀a. a → a” is more polymorphic than “∀a. [a] → [a]” (the type of the parameter of x). And indeed it’s easy to come up with a type-incorrect implementation as I’ve done here—if you could run it, you’d end up trying to call “drop” on a number instead of a list: f h f (\ z -> z 5) (\ z -> z 5) (drop 1) drop 1 5
- coldtea 8y ago>“Higher-rank polymorphism” sounds scarier than it is, too, by the way Well, there's some irony of writing that after 6 paragraphs filled with obscured Haskellise. (Even though it's just some basic functional stuff in the end, like map, drop, sum etc, they will definitely look alien to anyone uninitiated).
- evincarofautumn 8y agoGood point, haha, I did see the irony while writing that. I’ve moved that paragraph up toward the top in the hope it helps somewhat. :P The thing about a lot of these big scary concepts in Haskell is that they’re really simple, the kind of thing where, once it clicks, you go “Oh! That’s all?”, but that doesn’t mean they’re easy—you’ve got to get familiar with the notation and jargon, and spend time building intuition from examples and just trying things out. Nowadays I think of my process of learning new things as “try to realise how simple it is”, i.e. “what do experts understand (or just have memorised) that makes this easy for them?” It’s been a lot more motivating and pleasant than my old tactic of “try to cram this into my head without ‘chewing’ and hope my brain can digest it”.
- willtim 8y agoHaskell does make a great semi-formal language with which to communicate functional programming ideas, even if you have no plans to ever use an implementation of it. Mainstream languages are beginning to borrow more and more ideas from it (albeit often with significantly more verbose syntax). Much of the research literature in functional programming also uses Haskell syntax, so IMHO it is worth any developer learning.
- MaxBarraclough 8y agoThey say you need a PhD to use Haskell. It's not true. I've seen PhD students use it!
- deleted 8y ago[deleted]
- 8y ago
- keithnz 8y agoI find in C# you use it without thinking about it because it usually makes sense when you have specific things you are trying to do, you often don't think too much about the types. public interface ITalker { string Talk(); } public class Robot : ITalker { public string Talk() => "Beep Boop"; } class Program { static void Main(string[] args) { var robots = new List<Robot>() {new Robot()}; MakeEmTalk(robots); DoStuffWithRobots(MakeEmTalk, robots); } private static void DoStuffWithRobots(Action<List<Robot>> robotoStuff, List<Robot> robots) { robotoStuff(robots); } private static void MakeEmTalk(IEnumerable<ITalker> talkers) { foreach (var talker in talkers) { Console.WriteLine(talker.Talk()); } } } so intuitively, with MakeEmTalk, a list of robots should be able to be used where a enumerable list of talkers is required. conversely ( contraversely? ) with DoStuffWithRobots, an action on a list of robots should be able to use a more generic action that works on an enumerable of Italkers, while we are only dealing with a list of robots there shouldn't be anything that stops us using something designed to work on a broader set of things.
- willtim 8y agoStandardised principled abstractions make for better program composition and better API design. In the Haskell world, APIs are much less adhoc, more consistent and easily understood because of such mathematical abstractions (and the laws they imply, relating operations). Principled abstractions enable many other possibilities too, such as automatic program derivation. In Haskell I do not need to write any code to aggregate nested maps or parse a sudoku board, the compiler knows how to assemble such code automatically from the types.
- clhodapp 8y agoContravariance, as shown here, is extremely helpful for a working programmer and (like much of strongly typed functional programming) becomes more so as you begin to let go of that type of concern and just learn the stuff. Say you have a JSON encoder that knows how to turn Strings into JSON. You can make an encoder for UUIDs with that encoder and a simple contramap operation. In Scala: // First, summon a String encoder val stringEncoder: Encoder[String] = Encoder[String] // Now contramap val uuidEncoder: Encoder[UUID] = stringEncoder.contramap[UUID](_.toString) Of course, you're free to do this inline so there's not usually so much val-binding involved.
- evincarofautumn 8y agoProfunctors are another great example of variance in functional programming. A profunctor is a type constructor that takes two arguments, one contravariant, one covariant. The simplest example of this is the function type constructor (->): you can lmap (contramap) the first type parameter by composing a function before the input, or rmap (fmap) the second type parameter by composing a function after the result: pipe :: A -> B lmap (modifyInput :: A' -> A ) pipe :: A' -> B rmap (modifyOutput :: B -> B') pipe :: A -> B' Profunctors are the class of types that behave like functions or “pipes”—anything that takes input of some type and produces output of another type, which you want to be able to stitch together in a pipeline. In fact I think the only way to have a contravariant type parameter in Haskell is to use it on the left of a function arrow, or not use it at all; all Profunctor instances are wrappers for something function-like.
- merijnv 8y agoWell, one example I like when people ask me "what's contravariant good for?" is the following intuition. Suppose we're doing stream processing. We might have a datatype 'Source a' which is a source that produces a stream of a's. 'Source' is an example of a Functor. We can use 'fmap :: Functor f => (a -> b) -> f a -> f b' as '(a -> b) -> Source a -> Source b'. So, if we a function 'a -> b' we can turn a source of a's into a source of b's. Nice. In such a library we would obviously also want a 'Sink a', this is a datatype that consumes a stream of a's. For example, it might write these a's to disk. Now, clearly it doesn't make sense for 'Sink' to be a functor. Think about it, if we have a sink that writes a's to disk, how would a function 'a -> b' affect it? Sure, we could turn all a's into b's, but then what? We don't know how to do anything with b's. However, 'Sink' is a Contravariant (Functor). So, let's have a look at that. 'contramap :: Contravariant f => (a -> b) -> f b -> f a', so '(a -> b) -> Sink b -> Sink a'. If we have a 'Sink' that writes 'b' to disk and a function that turns a's into b's we can obviously construct a 'Sink a' that consumes a stream of a's, converts them to b's and passes them to the original 'Sink'. And then there's a third abstraction not mentioned in the original post. The 'Profunctor', a Profunctor is a type that has two arguments and is contravariant in the first one, while the second is a regular Functor. In other words, if we have 'Pipe a b' this type can be made a Profunctor which comes with 'dimap :: Profunctor p => (a -> b) -> (c -> d) -> p b c -> p a d', which hopefully looks very similar to both Functor and Contravariant. In our stream processing example 'Pipe a b' would correspond with a pipe that consumes a stream of a's and turns it into a stream of b's which we can use to plumb 'Sink' and 'Source' together. We can both contramap it's first type argument to change what we can feed into it, as well as fmap the second type argument to alter what it produces. These are from the only cases where these classes show up, but I hope they give some relatively easy to follow example on how these classes can capture some common scenario.
- Myrmornis 8y agoThanks for your reply, I'm going to try to read it and the others carefully. I wonder whether it's possible to bridge the gap more for people who are used to more conventional programming paradigms. For example, > 'Source' is an example of a Functor. We can use 'fmap :: Functor f => (a -> b) -> f a -> f b' as '(a -> b) -> Source a -> Source b'. So, if we a function 'a -> b' we can turn a source of a's into a source of b's. Nice. In python that's just itertools.imap(function_a_to_b, stream_of_as) and many other languages will have a similar construction. So I immediately hit a block as I'm struggling to understand why I need the notion of a Functor to understand what in the end looks like just lazy mapping of a function over a stream.
- jerf 8y agoThere are some other good replies already, but I really want to highlight that contravariance and covariance are not Haskell concepts; they're programming language concepts. They come up in object-oriented programming languages all the time too, especially once generics get involved. (Understanding the issues around -variance and generics in OO is an important part of the path of understanding why those sets of features aren't a thing you "just" put in a language, and why you end up with some sort of tradeoff in every implementation.) As is often the case, you can muddle through without an explicit understanding of the issues, but you'll be a better programmer (better designs, faster understanding why certain error messages come up, etc... I mean a practically better programmer, not just academically better) if you understand these issues well. Though I will confess that I have a hard time keeping straight which is co- and which is contra- once I step away from the issues for a while. I understand the concepts and their application at a practically-useful level, but I've never loved the naming scheme.
- ufo 8y agoVariance inevitable rears its ugly face when you introduce a subtyping relation to your type system. Variance is most visible in languages that mix parametric polymorphism (generics) with subtyping (for example, OO subclassing) because when this happens you may need to write down explicit variance annotations to appease the type checker. However, variance still matters even without parametric types. For example, it describes what types you can use when overriding a methof in a subclass. The subclass is allowed to be "more general" than the superclass so if you want you can change the input parameters of the method to a more general supertype and you can change the return type to a more specific subtype. The output types are covariant: in the subclass where you create the method you can use a subclass of the return type. The input types are contravariant: in the subclass where you create the method you use a superclass of the input types.
- mjpuser 8y agoI found this difficult to follow since it requires you to learn Haskell, but luckily typeclasses.com is a paid service to learn Haskell.
- YorkshireSeason 8y agoIf you speak Scala, I recommend that you familiarise yourself with Scala's implicits. The latter are a generalisation of type classes. You can implement the latter using the former, see e.g. [1]. If you speak ML/OCaml/F#, then Oleg's [2] might also be easily accessible. [1] B. C. Oliveira, A. Moors, and M. Odersky. Type classes as objects and implicits. https://ropas.snu.ac.kr/~bruno/papers/TypeClasses.pdf https://ropas.snu.ac.kr/~bruno/papers/TypeClasses.pdf [2] O. Kieselyov, Implementing, and Understanding Type Classes. http://okmij.org/ftp/Computation/typeclass.html http://okmij.org/ftp/Computation/typeclass.html
- mlevental 8y agoall content is now content marketing.
- allenleein 8y agoHere are some FREE Functional Programming (Haskell,Purescript)learning resources from 101 to building product: https://github.com/functionalflow/brains/projects/9 https://github.com/functionalflow/brains/projects/9 Over 20 FREE FP books: https://github.com/functionalflow/brains/tree/master/Zen-of-Functional-Programming https://github.com/functionalflow/brains/tree/master/Zen-of-...
- techno_modus 8y agoWhat does the property 'contravariant' or 'covariant' belong to: function, functor, class, type class, data type, function or method argument, function or method return value, generic type/collection or something else?
- tel 8y agoType variable. Since data definitions can bind new type variables, it's necessary to consider variance there and in the resulting type constructor, too.
- greydius 8y agoFrom a mathematics perspective, they're a property of mappings between two categories (called functors). I think part of the confusion us simple programmers have (aside from no knowledge of category theory) is that these words are used without explicitly stating what categories we're dealing with. In the case of typed functional programming (e.g. Haskell), we're usually thinking about functors as mappings from the category of types, where the morphisms are function types, back to itself. But in the object-oriented world, these words are usually used in the context of a category of types where the morphisms indicate a subtype relationship.
- hinkley 8y agoI’m a little surprised you only got two answers, because I’m pretty sure the answer depends on how you look at the problem. Mine is this: A function has variance because the types of its inputs or outputs are in a hierarchy. An object type system (or I suppose even a functor system?) with generics might leverage variance to make sure that the behavior of its own methods is consistent with the Substitution Principle (LSP). And many people will tldr this into variance being the tool to give you a type system that is LSP compatible. But it’s all about the operations, consuming and emitting types.
- mping 8y agoI think like this too. Would only like to add that I believe a type parameter is *variant in relation to a function/method, not just by itself. That's why some people say "appears in a covariant position " and so on.
- ajross 8y agoUnpopular opinion: co/contravariant representations represent exactly the point at which formal typesafety starts hurting instead of helping as an engineering practice. In all circumstances where you find the need to worry about this stuff to get your real-world code to build, your project would have been better served by an environment that just let you do trick with runtime validation, or even an unsafe typecast. The resulting code is cleaner, simpler, easier to maintain and more straightforward to evolve than the "correct" madness that results from proper functional analysis. I'll take my downvotes, thanks.
- draw_down 8y agoMy sentiments exactly.
- willtim 8y agoYou are I think making a comment about type parameters in object-oriented languages with subtyping? But why give up on static types so easily? How about a type system without subtyping (e.g. Haskell, OCaml) or even better one with structural row typing (e.g. OCaml, Purescript, Elm)? Type systems are a form of machine-checked proof of correctness, at least for certain classes of errors. Sometimes proving things is laborious and a pain, but it is worth it if one cares about correctness (and often less effort than writing more unit tests).
- Filligree 8y agoStructural row typing I hadn't heard about. Elm is on my to-learn list, so I'll look it up separately, but... Coming from Haskell, what's the elevator pitch for it?
- willtim 8y agoIMHO, structural types are a better starting point. Nominal types can always be created by wrapping a structural type, but it is difficult to go the other way round. A language with row types can give a type to a query or have named function arguments. Extensible row types allow even more forms of composition, for example functions that require the presence of certain fields but will ignore others, very similar to subtyping.
- cnowacek 8y agoConveniently, this link: https://www.pointfree.co/episodes/ep14-contravariance https://www.pointfree.co/episodes/ep14-contravariance was also on the front page of HN. It explores contravariance a bit more and provides some practical examples in Swift.