📜 ⬆️ ⬇️

Isomorphism to the rescue

"Isomorphism" is one of the basic concepts of modern mathematics. With specific examples in Haskell and C #, I not only explain 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:


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

C #
 int Add(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:


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

C #
 Func<int, int> Add_(int x) { 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:



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 given to me implicitly : all thanks to isomorphism — a two-way converter from one to the other . Having a little generalized types:


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

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 :


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

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

A little more math for particularly inquisitive.

In fact, it should look like this:


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

C #
 Compose(Curry, Uncurry) = Id Compose(Uncurry, Curry) = Id, где: T Id<T>(T arg) => 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) = Id Uncurry.Compose(Curry) = Id, где: public static Func<TArg, TFinalRes> Compose<TArg, TRes, TFinalRes>( this Func<TArg, TRes> first, Func<TRes, TFinalRes> second) { return arg => second(first(arg)); } 

Id should be understood as "nothing happened." Since an isomorphism is a two-way converter by definition, you can always 1) take one thing, 2) convert it to another, and 3) convert it back to the first. Only two such operations can be done: because at the first stage (No. 1), the choice is only of two options. And in both cases, the operation should lead to exactly the same result, as if nothing happened at all (it is for this reason that strict equality is involved - because nothing has changed at all, and not “something” has not changed).


In addition to this, there is a theorem that the id element is always unique. Note that the Id function is generic, polymorphic and therefore truly unique with respect to each specific type.


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, which is so useful in practice that it even forms the basis of many "advanced" programming languages ​​such as Haskell:


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

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

This isomorphism preserves the result of the delayed calculation 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 too 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 unwittingly 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 - it 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, an altered approach to testing.


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



Source: https://habr.com/ru/post/436756/