Data Abstraction: Theory And Practice

One of the greatest tools to tame complexity in a growing codebase, growing either via lines of code or via people, is through strategic information hiding — you may know this as encapsulation, modularization, or data abstraction. In this post, we'll explore data abstraction's definition and capabilities from a formal perspective and show to what extent we can achieve the ideal through Swift, TypeScript, OCaml, ReasonML, Rust, and Haskell. Knowing how to use data abstraction in your language of choice is important in the short term, but understanding and naming the general concepts equips you to understand these tools as our languages evolve and change. We'll end with an explanation of how a more advanced usage of abstract data types shows up in the comonadic UI framework used in barbq.

Background

Our brains prefer to compartmentalize and draw black boxes around modules while stitching together pieces of code. When designing or changing these pieces we can additionally create interfaces, traits, protocols, signatures, typeclasses (we'll use "interface" for the rest of this article) that hide implementation details and make these black boxes explicit. Moreover, we can use these interfaces as contracts between implementor and client to allow for easy concurrency in our code composition; the less we need to keep in our heads for each part of our system, the more parts of our system we can context switch between and can collaborate on with colleagues.1 These contracts also reduce that which implementors need to provide to clients to a minimum as that which can be derived others can attach by enriching or extending the given interfaces.

A perspective on data abstraction that we will not be exploring is that from the Object-Oriented school. We will only explore a type-theoretic perspective. The best way to enforce conventions is for them to be compile errors. The best way to formalize something is by connecting it to formal logic. This is not to say that there is nothing to learn from the OO-perspective, but it is this author's view that the logical perspective is more logical.2

Statically-Typed Data Abstraction

Statically typed programming languages supply three kinds of syntactic forms associated with data abstraction. The interface, or an abstract type, hides private details of the implementation from the client.

Interface

Swift TypeScript OCaml ReasonML Rust Haskell F# protocol ExampleProtocol { static func create ( _ s : String ) - > Self func read ( ) - > String }

Implementation

Swift TypeScript OCaml ReasonML Rust Haskell F# public struct Example { let s : string public static func create ( _ s : string ) - > Example { return Example ( s : s ) } public func read ( ) - > String { return self . s } }

Client

Swift TypeScript OCaml ReasonML Rust Haskell F# func client < E : ExampleInterface > ( ) { let ex = E . create ( "hello" ) ; println ( ex . read ( ) ) ; }

The surface syntax differs among programming languages, but through them all, you can identify a client interacting with an implementation through an interface. The extent to which they achieve the ideal, from a semantics perspective, is something we will study in this post. Studying the ideal equips the student with the capacity for applying these techniques across all programming languages rather than relearning what is truly the same each time a new language is presented.

To really understand what an interface does it must be equipped with laws. With sufficient laws, concrete implementations can be swapped without clients observing changes, or dually, clients can be written without implementations existing. "Sufficient laws" gives us both obvious property-based tests and a state known as representation independence, but this we will discuss in another post.

We can concisely communicate these laws through the use of interfaces that express algebraic structures. With enough practice, our whole industry can instantly be aware of some structures' associated laws just through name recognition.

We can imagine a tower we can attempt to climb whenever working on some piece of new code:

Algebraic Structures Lawful Interfaces Interfaces Concrete Code

On the bottom, there is that which is is formed with the least effort, buckets of plain code. We can add more order through interfaces. Further refine it with laws. And finally, lighten the burden of needing to constantly revisit laws through the identification of algebraic structures.

Sometimes we'll be unable to identify an algebraic structure, perhaps we don't want to put the time into discovering the laws, and we're just prototyping or writing glue so we don't want to come up with interfaces. But when necessary, the tower I've shown here gives us a strategy for simplifying pieces of code.

In this post, we'll focus only on the third layer, interfaces. Note that we've already talked a bit about the top layer in earlier posts starring algebraic structures. The second layer will be discussed in a follow-up post.

Idealized Data Abstraction

As stated earlier, understanding the concepts in your chosen language is useful now, but understanding them from a formal perspective will persist through your career. This post will show how these idealized forms manifest in mainstream languages as a tool for better internalizing these concepts.

To motivate the right way to think about abstract data types (interfaces), I want to contrast it to working with parametric polymorphism which you may know this as programming with "generic"s or "template"s.

Consider a function that takes in a list of arbitrary elements, A A A, and returns the length.

Swift TypeScript OCaml ReasonML Rust Haskell F# func length < A > ( es : [ A ] ) - > Int { return es . count }

When implementing the body of such parametrically polymorphic functions we're constrained to not rely on the type of A A A. In return, callers of our function have the liberty to choose any A A A — in our case they can pass a list of int s a list of string s or a list of frog s. This is also known as universal quantification of our type variable, A A A.

When defining such generic functions we're defining a family of functions: One for each choice of concrete type. This family is, in a sense, an infinite product2 of all such functions.

Consider an abstract data type representing an integer stack that is never empty. What we're describing is "some stacklike thing" that can push and pop integers.

Swift TypeScript OCaml ReasonML Rust Haskell F# public protocol StackProtocol { static func create ( def : Int ) - > Self func push ( elem : Int ) - > Self func pop ( ) - > ( Int , Self ) } enum List < A > { case empty case cons ( A , List < A > ) } public struct SimpleStack { let def : Int let data : List < Int > } extension SimpleStack : StackProtocol { static func create ( def : Int ) - > Stack { return Stack ( def : def , data : . empty ) } func push ( elem : Int ) - > Stack { return Stack ( def : self . def , data : . cons ( elem , self . data ) ) } func pop ( ) - > ( Int , Stack ) { switch self . data { case . empty : return ( self . def , self ) case . cons ( e , es ) : return ( e , Stack ( def : self . def , data : es ) ) } } }

When implementing our functions we have the liberty to rely on the concrete type. Essentially, the self is a parameter for some of these functions. The self passing is implicit in many languages, but, interestingly, very explicit in Rust. In contrast, users of our interface, callers of create, push, and pop, are constrained to not be able to rely on the concrete type of the stack.

When defining such abstract data types in a sense we're defining a family of constructors for data types: One for each choice of concrete implementation as we can forget the details that make these implementations unique. This family is, in a sense, an infinite sum; we have one variant for each concrete implementation.

In this way, parametric polymorphism is dual to data abstraction.

Through the Curry-Howard isomorphism4 a generic A A A in our types correspond to ∀ A \forall A ∀A in logic. In other words, a universally quantified type variable in type theory is isomorphic to a universally quantified propositional variable in logic. The dual of ∀ \forall ∀ is ∃ \exists ∃ or "there exists." Now we can go backward through Curry-Howard and land on the irrefutable conclusion that abstract types are existential types. There exists some concrete stack, where the implementor knows the underlying concrete representation, but as the client, we don't know the details. We existentially quantify over the type of the concrete representation.

Interface

Our idealized form of data abstraction will refer to abstract data types as ∃ t . τ \exists t.\tau ∃t.τ where τ \tau τ stands in for some time that depends on t t t. Concretely for stacks: ∃ t . ⟨ c r e a t e : i n t → t , p u s h : t → i n t → t , p o p : t → ( i n t × t ) ⟩ \exists t. \langle create : int \rightarrow t, push: t \rightarrow int \rightarrow t, pop: t \rightarrow (int \times t) \rangle ∃t.⟨create:int→t,push:t→int→t,pop:t→(int×t)⟩. In English, you may say, there exists some stack, t , with a create function from int to t , a push function from t and int to t , and a pop function from t to int and t .

Implementation

We can pack a chosen representation type, ρ \rho ρ, along with an implementation e e e replacing ρ \rho ρ for t t t in our existential box to create an abstract data type (or introducing a new variant to our infinite sum) ⋃ ρ ; e ; ∃ t . τ \bigcup \rho; e; \exists t.\tau ⋃ρ;e;∃t.τ5. Concretely for the stack example we can choose ρ \rho ρ to be the default int and a list storing the values pushed so far: ⋃ ( I n t ∗ L i s t [ I n t ] ) ; ⟨ c r e a t e = … , p u s h = … , p o p = … ⟩ ; ∃ t . ⟨ c r e a t e : u n i t → t , p u s h : t → i n t → t , p o p : t → ( i n t × t ) ⟩ \bigcup (Int * List[Int]) ; \langle create = \dots, push = \dots, pop = \dots \rangle ; \exists t. \langle create : unit \rightarrow t, push: t \rightarrow int \rightarrow t, pop: t \rightarrow (int \times t) \rangle ⋃(Int∗List[Int]);⟨create=…,push=…,pop=…⟩;∃t.⟨create:unit→t,push:t→int→t,pop:t→(int×t)⟩

Client

A client is an expression that opens a packed value for use under an environment where the choice of the existential t t t is opaque. The client must be able to run for all specific implementations. Due to these requirements, it's best to think of a client as a function6 of type ∀ t . τ → τ 2 \forall t. \tau \rightarrow \tau_2 ∀t.τ→τ2​. Note, we add a further restriction that t t t cannot show up in the return type τ 2 \tau_2 τ2​. We'll show below how this restriction increases the power of our abstract data type. Concretely for the stack example: a function that pops two ints off of our stack and returns their sum would have type ∀ t . ⟨ c r e a t e : u n i t → t , p u s h : t → i n t → t , p o p : t → ( i n t × t ) ⟩ → i n t \forall t. \langle create : unit \rightarrow t, push: t \rightarrow int \rightarrow t, pop: t \rightarrow (int \times t) \rangle \rightarrow int ∀t.⟨create:unit→t,push:t→int→t,pop:t→(int×t)⟩→int

Recall that these idealized forms manifest themselves with a subset of their power in our programming languages as shown below:

Swift TypeScript OCaml ReasonML Rust Haskell F# protocol ExampleProtocol { static func create ( _ s : String ) - > Self func read ( ) - > String }

Swift TypeScript OCaml ReasonML Rust Haskell F# public struct Example { let s : string public static func create ( _ s : string ) - > Example { return Example ( s : s ) } public func read ( ) - > String { return self . s } }

Swift TypeScript OCaml ReasonML Rust Haskell F# let ex = Example . create ( "hello" ) ; println ( ex . read ( ) ) ;

Properties of Abstract Data Types

In this section, we'll enumerate a few interesting properties of abstract data types first in their idealized forms and then in our mainstream languages. If you only want to see languages that can properly express all of these properties, skip to the OCaml or ReasonML versions of these code samples.

Implementations as values

In an ideal world, a packed implementation is a value. It is first-class. We can create it in an expression anonymously, we can accept it as an argument to a function, and we can return it from a function as well. ⋃ ρ ; e ; ∃ t . τ \bigcup \rho; e; \exists t.\tau ⋃ρ;e;∃t.τ can appear anywhere any other expression can appear.

Note: The seeming lack7 of power of Haskell is just due to this section occurring before I explain Rank2 types.

Swift TypeScript OCaml ReasonML Rust Haskell F# protocol Tau { var name : String { get } } func acceptTau ( tau : Tau ) { return tau . name } func acceptTau2 < Packed : Tau > ( p : Packed ) { return p . name }

This property provides many interesting capabilities that we won't enumerate in full. Here's just one: Coupled with the rule restricting the existentially quantified t t t from appearing in the result of client functions, we can use the first-classed-ness to allow for the unification of disparate concrete types in branches as long as they pack to the same interface. Our machines use dynamic dispatch to make this work at runtime.

Swift TypeScript OCaml ReasonML Rust Haskell F# func myPet ( preference : Preference ) - > Animal { switch preference { case . active : dog as Animal case . passive : cat as Animal } } func badMyPet < A : Animal > ( preference : Preference ) - > A { switch preference { case . active : dog as A case . passive : cat as A } }

Upcasting

In addition to being able to pack an implementation to an interface, we can also pack a more detailed interface into one that hides more information as long as we keep the same implementation type. Upcasting, ↑ τ + , τ − \uparrow_{\tau_{+},\tau_{-}} ↑τ+​,τ−​​, is a client that performs this refining accepting a τ + \tau_{+} τ+​ and returning a new packed τ − \tau_{-} τ−​.

For example, consider if we modelled a sort of iterator: ∃ t . ⟨ n e x t : … ⟩ \exists t.\langle next : \dots \rangle ∃t.⟨next:…⟩. We can refine a stack into this iterator by using pop. I'm intentionally making next have a distinct name different from pop to make this a bit harder to express in the languages we're comfortable using.

↑ τ s t a c k , τ i t e r a t o r = ∀ t . λ s : ⟨ … , p o p : t → ( i n t × t ) ⟩ . \uparrow_{\tau_{stack}, \tau_{iterator}} = \forall t. \lambda s : \langle \dots, pop : t \rightarrow (int \times t) \rangle. ↑τstack​,τiterator​​=∀t.λs:⟨…,pop:t→(int×t)⟩.

⋃ t ; ⟨ n e x t = s . p o p ⟩ ; ⟨ n e x t : t → ( i n t × t ) ⟩ \quad \bigcup t; \langle next = s.pop \rangle ; \langle next : t \rightarrow (int \times t) \rangle ⋃t;⟨next=s.pop⟩;⟨next:t→(int×t)⟩

Swift TypeScript OCaml ReasonML Rust Haskell F# protocol Iterator { func next ( ) - > ( Int , Self ) } public struct StackWithIterator < S : StackProtocol > { let stack : S } extension StackWithIterator : StackProtocol { static func create ( def : Int ) - > StackWithIterator < S > { return StackWithIterator ( stack : S . create ( def ) ) } func push ( elem : Int ) - > StackWithIterator < S > { return StackWithIterator ( stack : self . stack . push ( elem ) ) } func pop ( ) - > ( Int , StackWithIterator < S > ) { let ( elem , s ) = self . stack . pop ( ) ; return ( elem , StackWithIterator ( stack : s ) ) } } extension StackWithIterator : Iterator { func next ( ) - > ( Int , StackWithIterator < S > ) { return self . stack . pop ( ) } } protocol Iterator { associatedtype Container func next ( ) - > ( Int , Container ) } func upcastStackIterator < S : StackProtocol , I : Iterator > ( s : S ) - > I where I . Container == S { }

Extension

We can extend and enrich packed implementations with further behavior if that behavior is definable only relying on the existing behavior of the interface. Extension, ★ τ − , τ + \bigstar_{\tau_{-}, \tau_{+}} ★τ−​,τ+​​, is a client that performs this enriching operation on a τ − \tau_{-} τ−​ returning a new packed τ + \tau_{+} τ+​.

For example, consider if we wanted to expose a peek operation. Since this is definable exclusively with push and pop we can write an extension.

★ τ − , τ + = ∀ t . λ s : ⟨ … ⟩ . \bigstar_{\tau_{-}, \tau_{+}} = \forall t. \lambda s : \langle \dots \rangle. ★τ−​,τ+​​=∀t.λs:⟨…⟩.

⋃ t ; ⟨ … , p e e k = s n d ( p o p t ) ⟩ ; ⟨ … , p e e k : t → i n t ⟩ \quad \bigcup t; \langle \dots, peek = snd(pop \; t) \rangle ; \langle \dots, peek : t \rightarrow int \rangle ⋃t;⟨…,peek=snd(popt)⟩;⟨…,peek:t→int⟩

Swift TypeScript OCaml ReasonML Rust Haskell F# extension StackProtocol { func peek ( ) - > Self { let ( _ , s ) = self . pop ( ) return s } }

Interface composition

Given two interfaces we can compose them to create a new interface that has the contents of both ( ∃ t . τ 1 ) & ( ∃ s . τ 2 ) = ∃ t . ∃ s . τ 1 & τ 2 (\exists t.\tau_1) \& (\exists s.\tau_2) = \exists t.\exists s.\tau_1 \& \tau_2 (∃t.τ1​)&(∃s.τ2​)=∃t.∃s.τ1​&τ2​.

Given two packed implementations we can combine them to this composed interface:

p 1 ∘ p 2 : ( ∃ s . τ 1 ) → ( ∃ t . τ 2 ) → ∃ s . ∃ t . ( τ 1 & τ 2 ) = ∀ s . λ p 1 . ∀ t . λ p 2 . p_1 \circ p_2 : (\exists s.\tau_1) \rightarrow (\exists t.\tau_2) \rightarrow \exists s.\exists t.(\tau_1 \& \tau_2) = \forall s. \lambda p_1. \forall t. \lambda p_2. p1​∘p2​:(∃s.τ1​)→(∃t.τ2​)→∃s.∃t.(τ1​&τ2​)=∀s.λp1​.∀t.λp2​.

⋃ s ; ( ⋃ t ; τ 1 & τ 2 ; ∃ t . ( τ 1 & τ 2 ) ) ; \quad \bigcup s; (\bigcup t; \tau_1 \& \tau_2; \exists t.(\tau_1 \& \tau_2)); ⋃s;(⋃t;τ1​&τ2​;∃t.(τ1​&τ2​));

∃ s . ∃ t . ( τ 1 & τ 2 ) \quad \exists s.\exists t.(\tau_1 \& \tau_2) ∃s.∃t.(τ1​&τ2​)

Swift TypeScript OCaml ReasonML Rust Haskell F# public protocol Push { func push ( elem : Int ) - > Self } public protocol Pop { func pop ( ) - > ( Int , Self ) } public protocol PushPop : Push , Pop { } extension Stack : Push { } extension Stack : Pop { } extension Stack : PushPop { }

Existential Types As Rank2 Universal Types

It is surprisingly possible to represent the existential abstract data types entirely using ∀ \forall ∀s in programming languages where rank2 types are expressible. Rank2 types are polymorphic types where the ∀ \forall ∀ quantifier can appear parenthesized to the left of a → \rightarrow →, or equivalently, to the right of a data type declaration.

Swift TypeScript OCaml ReasonML Rust Haskell F#

Now for the intuition as to why data abstraction's existential interface, the packed implementation, and client are all definable using just the universal ∀ \forall ∀:

Recall that a client is a function that is polymorphic over the choice of concrete t t t, it's a function ∀ t . τ → τ 2 \forall t. \tau \rightarrow \tau_2 ∀t.τ→τ2​.

Let us consider our abstract data type, the packed implementation, as a process instead of an object. It's a machine that can take a client that acts on arbitrary implementations and return something (be it an Int , a String , a Frog — let's call it u u u). This machine runs the client on the implementation's representation type and just returns that result.

We can translate that explanation into a type ∀ u . ( ∀ t . τ → u ) → u \forall u. (\forall t.\tau \rightarrow u) \rightarrow u ∀u.(∀t.τ→u)→u. Restated: An abstract data type is a function that takes a client and runs it on some underlying implementation of τ \tau τ with some concrete choice for t t t. The caller of this machine picks u u u and the machine (the packed implementation) picks the t t t as it's exactly the hidden representation type of the implementation; thus explaining why the caller needs to pass a client that can run ∀ t \forall t ∀t. In this formulation, it is relatively straightforward to define precise implementations for the packed implementation and client objects.8

Rank2 Types for Simpler Mutually Recursive Types

In the Comonadic UI, there are two types that are effectively mutually recursive, but use a rank2 type, effectively an existensial type, to lower complexity during framework usage.9

The two types are a UI at this moment, and a Component modeling UI changing over time. I'll share a simplified version of these types now.

Recall a reducer as described in an earlier post.

Swift TypeScript OCaml ReasonML Haskell F# typealias Reducer < S , A > = Func < A , Endo < S > >

Now, the rank2 UI type:

newtype UI a v = UI ( forall component . ( Reducer a component -> v ) )

This type represents a UI at this moment. A UI is fed a function for firing actions a to evolve the component and in response returns some representation of a view.

newtype Component w m v = Component ( w ( UI ( m ( ) ) v ) )

Think of a component as a space of all possible future instantaneous UIs with a pointer at the current one. You can avoid thinking about the w in too much detail; just know that the w defines the space of possible futures. m defines a way to transition between the states.

Notice that the UI doesn't depend directly on Component but does so indirectly through a rank2 type. When these types are driven by the runtime, the quantified component is replaced by the Component that the UI was originally a part of. It is a corecursive process that goes on forever — this is what we want as we don't want our UI to ever stop responding to actions.

The rank2 type here grants us the ability to not need to talk about the w and m type parameters backing the Component whenever we only want to concern ourselves with the UI . We've simplified the interface for consumers of the library.

I'll explain these data types further as well as the comonadic UI framework as a whole in later posts.

Conclusion

Data abstraction helps us work on large codebases with ourselves and others by giving us tools to share and reuse code more easily. The Tower of Taming Complexity argues we can clarify code with interfaces, clarify interfaces with laws, and clarify lawful interfaces with algebraic structures. The programming languages we use every day have a way to express interfaces, implementations, and clients, but rather than thinking about the theory of data abstraction through our favorite language, we use an idealized one. Idealized data abstraction, thinking about abstract data types as the dual to parametric polymorphism, as existentials, shows us not only what we can achieve in our existing languages today but what we hope to achieve in future ones. Finally, we saw that existential types can be expressed with rank2 universal types and dug a slightly deeper into the comonadic UI framework.

Next time, we'll cover the part of the tower on lawful interfaces. We'll dig into representation independence and discuss mechanically discovering laws associated with interfaces. Plus how those laws guide us towards nice property-based tests for our code. Thus, granting us the courage within us to refactor without fear.

Thank you Chris Eidhof and Daira Hopwood for pointing out some mistakes in early versions of this post! Thank you Janne Siera for adding F# examples to the post!

Sources

I heavily relied on Mitchell and Plotkin's "Abstract Types Have Existential Type" and Chapter 17 of Practical Foundations of Programming Languages (PFPL) by Bob Harper, and, of course, Wikipedia when writing this post. "Abstract Types Have Existential Type" more thoroughly talks through the different forms of composition and power abstract types have and PFPL introduces the existential, pack, and open syntactic forms, shows typing rules and provides one take on the representability with rank-2 types. I intended to repackage these pieces of information in a simplified manner and reflecting on how this theory manifests within mainstream programming languages. If you want to learn more, I recommend reviewing these sources.