Isomorphism to the rescue

"Isomorphism" is one of the basic concepts of modern mathematics. With specific examples in Haskell and C #, I not only interpret the theory for non-mathematicians (without using any obscure mathematical symbols and terms), but also show how this can be used in everyday practice.

The problem is that strict equality (for example, 2 + 2 = 4) is often overly strict. Here is an example:

add :: (a, a) -> a
add (x, y) = x + y

C #
intAdd(Tuple<int, int> pair) {
  return pair.Item1 + pair.Item2;

However, there is one more - more cunning and in many situations much more practical - a way to define in some sense the same function:

add' :: a -> a -> a
add' x = \y -> x + y

C #
Func<int, int> Add_(intx) {
  return y => x + y;

Contrary to the obvious fact that for any two x, y, both functions always return the same result, they do not satisfy strict equality:

  • the first function immediately returns the amount (i.e., performs the calculation at the time of export),
  • while the second function returns another function (which ultimately returns the sum — if someone calls it, of course, otherwise no calculation will be performed: this is an example of deferred calculation and here too there is an isomorphism to which I will return a little later).

And this is "being too strict."

Isomorphism is “quite strict”; it does not require full, all-embracing equality, but is limited to equality "in a certain sense," which is always conditioned by a specific context.

As we guessed, both definitions above are isomorphic. This means exactly the following: if only one of them is given to me, then both of them are implicitly given to me at once: all due to isomorphism — a two-way converter from one to the other . A little generalizing types:

curry :: ((a, b) → c) → a → b → c
curry f x y = f (x, y),
uncurry :: (a → b → c) → (a, b) → c
uncurry f (x, y) = f x y

C #
Func<TArg1, Func<TArg2, TRes>> Curry(Func<Tuple<TArg1, TArg2>, TRes> uncurried) {
  return arg1 => arg2 => uncurried(Tuple.Create(arg1, arg2));
Func<Tuple<TArg1, TArg2>, TRes> Uncurry(Func<TArg1, Func<TArg2, TRes>> curried) {
  return pair => curried(pair.Item1)(pair.Item2);

... and now for any x, y :

curry add $ x, y = uncurry add' $ (x, y)

C #
Curry(Add)(x)(y) = Uncurry(Add_)(Tuple.Create(x, y))

Slightly more math for particularly inquisitive

In fact, it should look like this:

curry . uncurry = id
uncurry . curry = id
id x = x

C #
Compose(Curry, Uncurry) = IdCompose(Uncurry, Curry) = Id, где:
TId<T>(Targ) => arg;
Func<TArg, TFinalRes> Compose<TArg, TRes, TFinalRes>(
  Func<TArg, TRes> first, 
  Func<TRes, TFinalRes> second) {
  return arg => second(first(arg));
...или как extension-метод (определение функции Id остается таким же):
Curry.Compose(Uncurry) = IdUncurry.Compose(Curry) = Id, где:
publicstaticFunc<TArg, TFinalRes> Compose<TArg, TRes, TFinalRes>(
  thisFunc<TArg, TRes> first, 
  Func<TRes, TFinalRes> second) {
  return arg => second(first(arg));

Id следует понимать как "ничего не произошло". Поскольку изоморфизм это двухсторонний преобразователь по определению, то всегда можно 1) взять что-то одно, 2) преобразовать это в другое и 3) преобразовать обратно в первое. Таких операций можно проделать всего две: потому что на первом этапе (№1) выбор всего лишь из двух вариантов. И в обоих случаях операция должна приводить ровно к тому же результату, как если бы вообще ничего не происходило (именно по этой причине задействовано строгое равенство — потому что вообще ничего не изменилось, а не "что-то" не изменилось).

Вдобавок к этому, существует теорема о том, что id элемент всегда уникален. Обратите внимание, что функция Id — generic, полиморфна и потому действительно уникальна по отношению к каждому конкретному типу.

Isomorphism turns out to be very, very useful precisely because it is strict, but not too. It retains certain important properties (in the example above, the same result with the same arguments), while allowing you to freely transform the data structures themselves (carriers of isomorphic behavior and properties). And it is absolutely safe - because isomorphism always works in both directions, which means you can always go back without losing those very "important properties". Let me give you another example that is so useful in practice that it is even the basis for many "advanced" programming languages ​​such as Haskell:

toLazy :: a -> () -> a
toLazy x = \_ -> a
fromLazy :: (() -> a) -> a
fromLazy f = f ()

C #
Func<TRes> Lazy(TResres) {
  return () => res;
TResLazy(Func<TRes> lazy) {
  return lazy();

This isomorphism preserves the result of the delayed computation itself - this is the “important property”, while the data structures are different.

Conclusion? The PLO, especially strictly typed, (forcedly) works at the level of "strict equality". And therefore - in the wake of the examples cited - it is often overly strict. When you get used to thinking “too strictly” (and this happens imperceptibly - it gradually leaks into the programmer, especially if he is not looking for inspiration in mathematics), your decisions involuntarily lose the desired (or at least objectively possible) flexibility. Understanding isomorphism - in collaboration with a conscious attempt to be more attentive to his
and to someone else's code - helps to define more clearly the circle of "important properties", abstracting from unnecessary details: namely, from specific data structures on which these "important properties" are captured (they are also “implementation details”, for that matter). First of all, it is a way of thinking and only then - more successful (micro) architectural solutions and, as a natural consequence, a recast approach to testing.

PS If I see that the article has benefited, I will return to the topics of "more successful (micro) architectural solutions" and "altered approach to testing."

Also popular now: