<<

Records with Rank Polymorphism

Justin Slepak Olin Shivers Panagiotis Manolios College of College of Computer Science College of Computer Science Northeastern University Northeastern University Northeastern University USA USA USA [email protected] [email protected] [email protected]

Abstract ACM, New York, NY, USA, 13 pages. https://doi.org/10.1145/3315454. In a rank-polymorphic programming language, all functions 3329961 automatically lift to operate on arbitrarily high-dimensional aggregate data. By adding records to such a language, we can 1 Introduction support computation on data frames, a tabular data struc- In a rank-polymorphic programming language, the primary ture containing heterogeneous data but in which individual control-flow mechanism is implicitly lifting functions toop- columns are homogeneous. In such a setting, a data frame erate on array arguments of arbitrarily many dimensions. is a vector of records, subject to both ordinary array op- Instead of having the programmer write out a loop nest, erations (e.g., filtering, reducing, sorting) and lifted record the programmer effectively writes only the loop body, and operations—projecting a field lifts to projecting a column. the iteration structure is determined automatically based Data frames have become a popular tool for exploratory data on the data being consumed. With iteration over the frame analysis, but fluidity of interacting with data frames via lifted included in the semantics of function application, extracting record operations depends on how the language’s records an individual element is not the common way to consume are designed. aggregate data. Instead, the language favors uniform treat- We investigate three languages with different notions of ment of each element. This encourages the programmer to record data: Racket, Standard ML, and Python. For each, write in a style that depends less on the exact shape of the we examine several common tasks for working with data data, makes opportunities for parallelism more apparent to frames and how the language’s records make these tasks easy a compiler, and facilitates exploratory coding due to a lack or hard. Based on their advantages and disadvantages, we of loop-nest boilerplate. synthesize their ideas to produce a design for record types Record types are also aggregate data, but it is expected that which is flexible for both scalar and lifted computation. different fields in a single record may have different types. CCS Concepts • Software and its engineering → Poly- Heterogeneity calls for the elements of a record to be treated morphism; Control structures; Data types and struc- individually rather than consuming such data through an tures; • Mathematics of computing → Exploratory data analog of rank-polymorphic function lifting. Even when analysis. record fields have the same type, it is not generally appropri- ate to treat them uniformly—e.g., temperature and latitude Keywords array-oriented languages, rank polymorphism, may both be stored as a number of degrees, but only one data frames, records should be convertible between Fahrenheit and Celsius. Libraries and even languages focused on “data frames,” ACM Reference Format: Justin Slepak, Olin Shivers, and Panagiotis Manolios. 2019. Records tabular data structures which are homogeneous within any with Rank Polymorphism. In Proceedings of the 6th ACM SIGPLAN column but potentially heterogeneous along a row, have International Workshop on Libraries, Languages and Compilers for arisen as popular tools for exploratory data analysis. We Array Programming (ARRAY ’19), June 22, 2019, Phoenix, AZ, USA. claim that data frames are the natural result of combining rank polymorphism with records. Permission to make digital or hard copies of all or part of this work for Even with concise theoretical intuition about computing personal or classroom use is granted without fee provided that copies with data frames, ease of use often depends on things a the- are not made or distributed for profit or commercial advantage and that copies bear this notice and the full citation on the first page. Copyrights oretician might dismiss as uninteresting details. Our goal is for components of this work owned by others than the author(s) must to examine several designs for heterogeneous record data be honored. Abstracting with credit is permitted. To copy otherwise, or and how those designs fare when lifted up to data frames. republish, to post on servers or to redistribute to lists, requires prior specific In this work, we explore the interaction of records and rank permission and/or a fee. Request permissions from [email protected]. polymorphism in settings where they are independent fea- ARRAY ’19, June 22, 2019, Phoenix, AZ, USA tures We investigate what we get for free simply for doing © 2019 Copyright held by the owner/author(s). Publication rights licensed to ACM. record computation using rank polymorphism as it already ACM ISBN 978-1-4503-6717-2/19/06...$15.00 exists without extra design work targeted at integrating the https://doi.org/10.1145/3315454.3329961 two features. ARRAY ’19, June 22, 2019, Phoenix, AZ, USA Justin Slepak, Olin Shivers, and Panagiotis Manolios

We demonstrate several common tasks using three pro- Suppose instead we gave [5 6 7] as an argument to a gramming systems’ notions of records: dot-prod function which expects two rank-1 arguments. 1. Racket’s structs within #lang remora/dynamic, a We would then have a scalar frame containing a single cell. rank-polymorphic DSL If the other argument’s shape is 2 × 3, we must grow our 2. Standard ML’s records, with aggregate lifting provided scalar frame into a 2-vector frame by replicating the entire by explicit use of map-like functions vector—the cell rank here is 1 instead of 0 in the previous 3. Python’s dictionaries, with a purpose-built library for case. So the replicated form is [[5 6 7] [5 6 7]]. working with data frames The rank which a function expects for that argument After consideration of how each version of records helps effectively determines whether the matrix produced by frame or hinders exploratory analysis, we sketch a design for het- expansion has the original vector as a column or as a row erogeneous record data which combines their respective because the decomposition of the argument shape into its advantages for data frame manipulation but also serves as a frame and cell portions determines where new dimensions sane design independent of our focus on data frames as an are added. Viewing an n-vector as a vector frame containing × ... application domain. scalar cells means it can be grown to an n m array, whereas viewing it as a scalar frame containing a vector cell 2 Rank Polymorphism means it can be grown to an m ... × n array. A common way of manipulating the iteration space is In the rank-polymorphic programming model, the universe choosing where in an argument shape new dimensions can of data consists of regular arrays, whose “shape” is a se- be added. Consider a simple vector-matrix addition: quence of natural-number dimensions. A function’s defini- tion includes the “ranks” of its arguments, i.e., the number of (+ [10 20] dimensions each argument is expected to have. A polynomial [[1 2] evaluation function poly-eval would take a rank-1 argu- [3 4]]) ment containing the polynomial’s coefficients and a rank-0 The function + expects scalar arguments. When we split the (i.e., scalar) argument for the number at which to evaluate actual arguments’ shapes—[2] and [2 2] respectively—into the polynomial. For a matrix inversion function minv, there frame and cell portions, they are frame = [2] and cell = [] would be a single argument of rank 2. for the first argument and frame = [2 2] and cell = [] for When a function is applied to arguments of higher rank, the second. We must extend the first argument’s [2] frame those arguments are viewed like nested arrays. Applying to [2 2] by replicating scalar cells. So the 10 cell expands to minv to an array with shape 3 × 4 × 4 treats that argument as [10 10] and 20 to [20 20]. The first argument is therefore a 3-vector whose elements are 4 × 4 matrices. That 3-vector treated like [[10 10] [20 20]]. If we instead used a vec+ forms the implicit iteration space: we must invert each of function which expects vector arguments, the frame shapes the three matrices, producing a 3-vector of result matrices would be [] and [2]. Expanding [] to [2] would replicate which we then reassemble into a 3 × 4 × 4 result. The individ- the vector cell [10 20] rather than each scalar cell. So we ual matrices in this scenario are called the “cells;” they are treat the first argument as [[10 20] [10 20]]. the fundamental unit on which the function operates. The The behavior of vec+ could be described as breaking its “frame” is the sequence of leading dimensions in an argu- arguments into vector cells and then applying + to those vec- ment’s shape which drives the implicit iteration—appending tors (which internally breaks the vectors into scalar cells to the frame and cell shape gives the entire argument shape. add pointwise). So vec+ is a “reranked” version of +. Rerank- Here, our 4 × 4-matrix cells are laid out in a 3-vector frame. ing is a common enough technique in rank-polymorphic We could also use this 3 × 4 × 4 array as the coefficient ar- programming to warrant some language-level support, such gument to poly-eval, in which case it would be viewed as as J’s " operator. 4-vector cells in a 3 × 4-matrix frame. Frames are compatible if and only if one is a prefix of the 2.1 A Rank-Polymorphic Language other. During function application, each argument’s frame In order to demonstrate use of rank polymorphism we will is extended to match the longest argument’s frame, by repli- use a Racket-based DSL called #lang remora/dynamic. It is poly-eval cating the argument cells. Continuing with and a prototype implementation of a dynamically typed variant × × [5 6 7] the 3 4 4 coefficient array, a 3-vector of values of Remora [11], a higher-order, rank-polymorphic program- × would have to be grown to the 3 4 matrix ming language. The syntax for this DSL is largely inherited [[5 5 5 5] from Racket. The two major differences are nested bracket [6 6 6 6] notation for array construction and rank annotations on [7 7 7 7]] formal parameters. Recall that the cell rank poly-eval expects for this argument An array literal can be written out with an explicit shape, is 0. So each scalar must be replicated on its own 4 times. such as this 2 × 3 matrix: Records with Rank Polymorphism ARRAY ’19, June 22, 2019, Phoenix, AZ, USA

> (define arr1 (alit (2 3) 3 4 5 6 7 8)) Using sum along the major axis of the 2 × 3 array arr1 pro- sum It can also be written using nested brackets. Brackets around duces a 3-vector result. Asking for the along the “rank-2 arr1 a sequence of expressions builds an array with one more axis” changes nothing, since that is already ’s major ~(1)sum sum dimension than those expressions’ results. If xs, ys, and axis. With , reranking to use the rank-1 axis, sum zs are each 4 × 2 arrays, then [xs ys zs] is a 3 × 4 × 2 we apply the function to each individual vector within arr1 array. Since a bracketed list is itself an expression, it can ap- and get two result cells, assembled in a vector frame. pear inside another bracketed list. While there is still a con- > (sum arr1) ceptual distinction between expressions and atoms, #lang [9 11 13] remora/dynamic implicitly promotes any syntactic atom ap- > (~(2)sum arr1) pearing where an expression is expected to a scalar array [9 11 13] with that as its sole atom. So even though numerals are syn- > (~(1)sum arr1) tactically atoms, they can be used as function arguments, [12 21] returned values, or bracketed-array components. Combining these notational conveniences, we have another way to write It is necessary to have an escape hatch from working out the same matrix shown above as a literal: entirely with regular data. This necessity may arise from raw input data itself, such as a collection of strings of non- > (define arr2 [[3 4 5] [6 7 8]]) uniform length. Another potential cause is that certain array > (equal arr1 arr2) operations produce output whose shape depends on input #t values (not just the input shape). Lifting such an operation When writing out a function, whether with define or λ, can produce result cells of differing shape, which must some- each formal parameter is marked with the rank of the cells how be assembled into the same frame. Since cells of differing into which its argument should be divided during function shape cannot coexist on their own, we use a “box” as a wrap- application. Within the function body, that variable is bound per. A box is an atom, but it contains an arbitrary array. So to an array of that rank. Applying this polynomial evaluation we can produce ragged data when necessary: function will treat the first argument as containing vector > (define ragged cells and the second argument as containing scalar cells. [(box [1 2]) The use of the variable coeffs can only mean a vector, but (box [3 4 5])]) a single application of poly-eval might repeat this many times with coeffs referring to different vectors. Consuming boxed data requires unpacking the box’s con- tents, which is done like let-binding. The let-bound contents > (define (poly-eval (coeffs 1) (x 0)) can then be consumed like an ordinary array. (reduce + 0 (* coeffs > ((λ ((some-box 0)) (expt x (unbox contents some-box (iota [(length coeffs)]))))) (sum contents))) ragged) Any natural number is a valid cell-rank specifier, but a formal [3 12] parameter can also be given the rank all, indicating that the entire argument should be treated as a single cell. The However, if the computation we intend to perform on the all rank is typically used in layout-manipulating operations, box’s contents has a result shape dependent on input shape, like rotate and reverse, as well as reduction and scan op- it is safest to produce boxed output, in case we happen to be erations operating along an array’s major axis. For example, lifting over an array of many boxes. this sum function computes the sum along its argument’s > (( ((some-box 0)) major axis, turning an array with shape [m n ...] into one λ (unbox contents some-box with shape [n ...]. (reverse contents))) > (define (sum (x all)) ragged) (reduce + 0 x)) Result cells have mismatched shapes If we want to sum an array along some other axis, we > ((λ((some-box 0)) could do so with a reranked version of sum. There is syntactic (unbox contents some-box sugar for reranking in #lang remora/dynamic: Preceding (box (reverse contents)))) a function with a ~ and a parenthesized list of argument ragged) ranks produces the corresponding η-expansion. So ~(2)sum [(box [2 1]) expands (with a fresh variable a) to (box [5 4 3])] (λ ((a 2)) (sum a)) ARRAY ’19, June 22, 2019, Phoenix, AZ, USA Justin Slepak, Olin Shivers, and Panagiotis Manolios

2.2 Heterogeneous Data > (weather-loc dallas-temp) Rank polymorphism facilitates uniform treatment of homo- "Dallas" geneous data, but not heterogeneous data such as tuples or > (weather-year dallas-temp) records. Converting a series of temperature readings from 2019 Fahrenheit to Celsius makes sense, but not a log entry con- > (weather-lo dallas-temp) taining a city name, temperature, and time of day. Although 57 heterogeneous data calls for heterogeneous treatment, it is still common to work with regular arrays whose elements 3.1 Table Creation themselves are all of one particular type of heterogeneous We can build a data frame as a vector containing several data. Our hypothetical weather report may be a single item weather structs. in a large table. Such use cases are common enough to have > (define temp-readings spawned several popular programming systems—such as R [dallas-temp [10] and Pandas [8]—for working with “data frames,” rectan- (weather "Dublin" 1 4 2019 11 5) gular tables which are heterogeneous along the row axis but (weather "Nome" 31 3 2019 31 26) homogeneous within any single column. Typical operations (weather "Tunis" 31 3 2019 21 12)]) on a data frame include: This table could also have been built from columns instead 1. Constructing a data frame from a collection of rows of rows. Since weather is a function expecting six scalar or columns arguments, it can be applied to vector arguments to get a 2. Extracting an individual column vector of structs. 3. Transforming data in a column > (define temp-readings 4. Selecting the subset of rows which fit some predicate, (weather such as certain column values ["Dallas" "Dublin" "Nome" "Tunis"] 5. Grouping rows into separate tables by partitioning a [28 1 31 31] column’s values [3 4 3 3] 6. Summarizing grouped rows 2019 [74 11 31 21] Although we have settled on a fixed notion of how to op- [57 5 26 12])) erate on array data, when we envision data frames as arrays Note that the year argument we gave is still scalar, meaning of records (treating records as atoms from rank polymor- all of our temperature readings are from the same year. When phism’s perspective) ergonomic convenience of the above function application lifts all arguments to have the same operations depends heavily on how records themselves are frame shape, the scalar 2019 is promoted to a vector with consumed and produced. Since Remora is statically typed, 2019 for every atom. we would like to keep an eye towards typability in sketching a design for records. 3.2 Column Extraction In the same way that the constructor function weather lifts 3 Candidate 1: Racket-style Structure over arrays of field values, field-accessor functions lift over In #lang remora/dynamic, we have access to quite a lot arrays of structs: of Racket’s built-in machinery, including structs, the pre- > (weather-loc temp-readings) ferred form of record data. We can define individual table ["Dallas" "Dublin" "Nome" "Tunis"] rows as structs, but we must first define the struct type > (weather-lo temp-readings) we intend to use: [57 5 26 12] Although we constructed this data frame with a scalar as > (struct weather (loc day month year hi lo)) one of the column arguments, we still have a vector result when we extract that column. This structure-type declaration defines weather as a function which produces a structure when given the field values: > (weather-year temp-readings) [2019 2019 2019 2019] > (define dallas-temp This method of column extraction gives the same result for (weather "Dallas" 28 3 2019 74 57)) either definition of temp-readings. Whether the data frame was constructed as a vector of row values or by lifting the It also defines functions weather-loc, weather-day, etc., constructor over the intended columns, the resulting array which we can use to access individual struct fields. value is the same. Records with Rank Polymorphism ARRAY ’19, June 22, 2019, Phoenix, AZ, USA

If we decide to focus our investigation on recorded high > (define (weather-lo- w l) temperatures, it might be convenient to have a table which (weather (weather-loc w) elides the lows. Here we hit a snag. By reranking a vector of (weather-day w) field accessors, we can build an array—but not a proper data (weather-month w) frame—containing the information we want. (weather-year w) l > (~(0)[weather-loc weather-day (weather-hi w))) weather-month weather-hi] temp-readings) If we plan to eventually integrate this data into a larger multi- [["Dallas" 28 3 74] year dataset, with a year column indexed from the start date ["Dublin" 1 4 11] of data collection, we may need to replace 2019 with some ["Nome" 31 3 31] particular offset from the start year. ["Tunis" 31 3 21]] > (define year-adjusted (weather-year-set temp-readings offset)) Here we have a rank-2 array, a table whose rows are hetero- Looking over our reported temperatures, something seems geneous vectors. A design which requires vectors to support off: Why is Tunis colder than Nome? Then it dawns onus differing atom types is undesirable for performance reasons that our reporting stations in the USA must have given tem- and difficult to build a suitable for. peratures in Fahrenheit. As long as we can recognize which Producing a vector of structs—which must have a differ- locations are in the USA, presumably using an in-usa? predi- ent number of fields since we are eliding some of weather’s cate, we can write a function to normalize temperature data1. fields—requires defining a new struct type. > (define (f->c (t 0)) > (struct (weather-v2 loc day month hi)) (/ (- t 32) 1.8)) > (define (normalize-temps (w 0)) We then have a more awkward transformation function. (select (in-usa? (weather-loc w)) > (define hi-readings (weather ((λ ((w 0)) (weather-loc w) (weather-v2 (weather-loc w) (weather-day w) (weather-day w) (weather-month w) (weather-month w) (weather-year w) (weather-hi w)) (f->c (weather-hi w)) temp-readings)) (f->c (weather-lo w))) w)) This could be more convenient with a composition form which preprocesses each argument with a unary function Updating a column conditionally according to values in other before passing them all along to a final higher-arity function. columns can then use the same implicit . We will use the notation > (normalize-temps temp-readings) (comp* f g ...) [(weather "Dallas" 28 3 2019 23.33 13.89) (weather "Dublin" 1 4 2019 11 5) as syntactic sugar for (weather "Nome" 31 3 2019 -0.56 -3.33) (λ ((x all) ...) (f (g x) ...)) (weather "Tunis" 31 3 2019 21 12)] with a fresh x for each g. 3.4 Row Filter > (define hi-readings The filter function takes two arguments: a boolean vector ((comp* weather-v2 to use as a mask and an array whose leading axis has the weather-loc weather-day same length as that boolean vector. The result array includes weather-month weather-hi) the sub-arrays from the second argument in positions cor- temp-readings)) responding to the boolean vector’s true entries. This is in contrast to the usual functional filter, which takes a predi- cate function as its first argument; it is rather a specialization 3.3 Column Update of the “replicate”/“compress” function from APL tradition. struct A Racket type definition does not automatically cre- 1In the interest of keeping control flow regular, conditionals in Remora use ate field-update functions (except for fields designated as a boolean value to select between two possible result values, which are both mutable), though such functions could be defined: computed eagerly. ARRAY ’19, June 22, 2019, Phoenix, AZ, USA Justin Slepak, Olin Shivers, and Panagiotis Manolios

Suppose we want to look at only the temperature reports > (filter [#t #f #t] [1 2 3]) from Dublin. [1 3] > (define (from-dublin? (w 0)) > (filter [[#t #f #t] [#f #t #f]] [1 2 3]) (string=? "Dublin" (weather-loc w))) Result cells have mismatched shapes > (define d (from-dublin? temp-readings)) > (filter* [[#t #f #t] [#f #t #f]] [1 2 3]) > d [(box [1 3]) (box [2])] [#f #t #f #f] We can construct our two boolean vectors for identifying > (filter d temp-readings) USA and non-USA temperature reports. Note that [id not] [(weather "Dublin" 1 4 2019 11 5)] is a vector of scalar functions. If it appears in function po- If the order of entries in the table is unimportant, we sition, it contributes its own frame, [2], to the function now have an alternative strategy for fixing our temperature application’s overall frame. This is our desired frame, but scale disparity. Since the temp-readings table is a vector of it is incompatible with the frame of the temperature read- structs, we can isolate the rows with USA locations: ings table (which is some vector of length much more than 2). We need this to-all function as an adapter. It converts > ((compose in-usa? weather-loc) temp-readings) each individual function to a function which consumes its [#t #f #t #f] entire argument as one cell (before then passing it to the > (filter ((compose in-usa? weather-loc) underlying function). temp-readings) temp-readings) > (define (to-all (f 0)) [(weather "Dallas" 28 3 2019 74 57) (λ ((a all)) (f a))) (weather "Nome" 31 3 2019 31 26)] When we apply (to-all [id not]), we have a vector of > (filter (not ((compose in-usa? weather-loc) all-ranked functions, so the argument is considered to have temp-readings)) a scalar frame. That means the argument will be passed in temp-readings) its entirety to each of id and not. [(weather "Dublin" 1 4 2019 11 5) (weather "Tunis" 31 3 2019 21 12)] > (define in-or-out ((to-all [id not]) Using the boolean mask which selects reports from sources ((compose in-usa? weather-loc) in the USA, we can put their Celsius-converted temperatures temp-readings))) into a new table with the unaltered non-USA reports. > in-or-out > (append [[#t #f #t #f] (normalize-temps [#f #t #f #t]] (filter ((compose in-usa? weather-loc) So a general split function can be written as temp-readings) temp-readings)) > (define (split (pred 0) (tbl all)) (filter (not ((compose in-usa? weather-loc) (filter* ((to-all [id not]) (pred tbl)) temp-readings)) tbl)) temp-readings)) > (split (compose in-usa? weather-loc) [(weather "Dallas" 28 3 2019 23.33 13.89) temp-readings) (weather "Nome" 31 3 2019 -0.56 -3.33) [(box [(weather "Dallas" 28 3 2019 74 57) (weather "Dublin" 1 4 2019 11 5) (weather "Nome" 31 3 2019 31 26)]) (weather "Tunis" 31 3 2019 21 12)] (box [(weather "Dublin" 28 3 2019 74 57) (weather "Tunis" 31 3 2019 31 26)])] 3.5 Row Partition With filter* able to lift over multiple boolean vectors, Generalizing from the previous example, it is useful to have a we can consider other ways to generate collections of boolean suite of ways to break up a table according to the values in its vectors to partition the table. The core of the task is to gen- rows. A function for splitting a table into rows that match a erate the vector of predicates, which can then be applied to predicate and rows that don’t will often produce ragged data, each element in the table. so the individual partitions of the table must be wrapped as A numeric column might be partitioned into ranges. We boxes. We have a filter* function, meant to be a version of can build each range’s predicate by combining the upper- 2 filter which is safe for lifting over several different boolean and lower-bound predicates: vectors. While applying filter will raise a dynamic error if it is lifted over boolean vectors with different numbers of #t 2Here we borrow Racket’s and/c combinator, which works like and, but elements, filter* keeps each result cell in a box. lifted to operate on predicates. Records with Rank Polymorphism ARRAY ’19, June 22, 2019, Phoenix, AZ, USA

> (define (bins (bounds 1)) names must be declared and named before use, and every (and/c (append [(const #t)] column selection must give that combination’s name as well. ((curry <) bounds)) (append ((curry >=) bounds) 4 Candidate 2: Standard ML-style Record [(const #t)]))) One alternative design for labeled heterogeneous data which Then filtering the vector [1 3 5 7 9 2 4 6 8] accord- avoids some of the shortcomings of Racket’s structs is Stan- ing to the result from (bins [5 10]) gives the following dard ML’s records [9]. In Standard ML, a record type consists partitioned results: of a (statically specified) set of field names and a required type for each field. In contrast to Racket’s struct types, Standard [(rem-box [1 3 5 2 4]) ML does not require record types themselves to be named. (rem-box [7 9 6 8]) However, Standard ML lacks Remora’s automatic lifting over (rem-box [])] argument frames, so producing working code requires sprin- We might also want to partition our table so as to group kling explicit map and replication operations throughout. In temperature readings from the same location. This requires the interest of keeping the focus on records, rather than on identifying the set of unique locations mentioned in the table ad hoc implementations of rank polymorphism, we will elide and then constructing an equality predicate matching each the definitions of most of these utility functions. Past work of them. has considered automatically generating the required map > (define (uniques (v 1)) and replication operations for a substantial subset of cases (to-all handled by rank polymorphism [12]. ((curry equal?) (nub v)))) 4.1 Table Creation nub The function condenses a vector down to a vector of its Our example row from Section3 can be written as unique elements. So the result from uniques is a vector of - val dallas_temp = {loc="Dallas", day=28, predicates, each of which checks whether its argument is month=3, year=2019, hi=74, lo=57} equal to the corresponding element of the original vector. If we generate this collection of predicates from our table’s As in Racket, several rows can be packed into a list, but we location column and then apply them to that column, we do not have a direct way to construct a table from columns. get a collection of boolean vectors each of which indicates Record creation in Standard ML is special syntax rather than which rows have a particular location. So filter* will then a function, so we cannot map it over lists of column values build a list of tables, with the rows grouped according to without explicitly defining a record-creation function and a their location fields. map variant of appropriate arity. - fun weather(lc,d,m,y,h,l) = 3.6 Ergonomics loc=lc, day=d, month=m, year=y, hi=h, lo=l; We get quite a lot of flexibility in data manipulation by com- - val temp_readings = map6 weather bining orthogonal features: rank polymorphism and tradi- (["Dallas","Dublin","Nome","Tunis"], tional array-manipulation operators with Racket’s built-in [28, 1, 31, 31], functional composition combinators and struct types. Con- [3, 4, 3, 3] structing tables from columns and computing on specific [2019, 2019, 2019, 2019] columns both fall out of making Racket’s pre-existing struct [74, 11, 31, 21] constructors and field accessors rank-polymorphic. Filtering [57, 5, 26, 12]) rows according to a predicate lifts to grouping rows by using The list repeating 2019 is due to the rank-monomorphic several non-overlapping predicates. nature of an explicit map. If we were to name our columns Some of the awkward code here arises from Racket’s some- before assembling them into a table, we could instead (ex- what clumsy point-free programming. Code for manipulat- plicitly) construct the repeated list: ing data frames often calls for unary predicates and trans- - fun rep n x = if (n <= 0) then [] formation functions, which we build by partially applying else x::(rep (n - 1) x); and adding plumbing between already-existing functions. Id- val rep = fn : int -> 'a -> 'a list iomatic Racket code mostly avoids point-free programming, - val cities = ["Dallas", "Dublin", in contrast to Haskell or J. In principle, fixing this should "Nome", "Tunis"]; only require a combinator library better suited for concise ... point-free code. - val years = rep (length cities) 2019; The more persistent problem with using Racket structs Or alternatively as data-frame rows is in tying each struct operation to a particular declared type. Every combination of column - val years = map (fn _ => 2019) cities; ARRAY ’19, June 22, 2019, Phoenix, AZ, USA Justin Slepak, Olin Shivers, and Panagiotis Manolios

4.2 Column Extraction - fun from_dublin w = #loc w = "Dublin"; We still have easy access to an individual column in Standard Error: unresolved flex record ML because record accessors are first-class functions. (can't tell what fields there are besides #loc) - map #hi temp_readings; - fun from_dublin val it = [74,11,31,21] : int list {loc = l, day = _, month = _, year = _, However, assembling a subset of columns still requires writ- hi = _, lo = _} = ing a row constructor for the new field set because it is l = "Dublin"; another case of constructing a table from columns. - fun hi_temp (c,h) = loc=c,hi=h; val hi_temp = fn : 'a * 'b -> hi:'b, loc:'a val from_dublin = fn - map2 hi_temp (map #loc temp_readings, : {day:'a, hi:'b, lo:'c, loc:string, map #hi temp_readings); month:'d, year:'e} -> bool val it = - List.filter from_dublin temp_readings; [hi=74,loc="Dallas", val it = [{day=1,hi=11,lo=5,loc="Dublin", hi=11,loc="Dublin", month=4,year=2019}] hi=31,loc="Nome", : {day:int, hi:int, lo:int, loc:string, hi=21,loc="Tunis"] month:int, year:int} list : hi:int, loc:string list A slight awkward point is that field access functions can- In principle, this restriction exists with Racket-style structs not be typed in isolation. If a field access function does not as well: each field accessor is tied to a particular struct type. appear in a context which constrains the particular field set of the record it will be applied to, the function does not have a single most general Standard ML type. This might 4.5 Row Partition impede a user who prefers to build up a collection of defined As in filtering, partitioning is as straightforward as is allowed data-manipulation gadgets for later reuse. by Standard ML’s field-set monomorphism. Again, Standard 4.3 Column Update ML’s lack of rank polymorphism requires extra code to adapt the filter function to an aggregate first argument and sin- As in Racket, we lack pre-existing functions for updating in- gular second argument, but once that adapter is available, dividual fields, and when we write our own, they are specific the table can be partitioned using a list of disjoint predicates. to the set of fields in the resulting record. For example, this adaptation of the previous section’s temperature normaliza- tion function only works on records with the same set of - fun many2one f xs y = map (fn x => f x y) xs; field names as our temp_reports table.3 val many2one = fn :('a -> 'b -> 'c) -> 'a list -> 'b -> 'c list - fun normalize_temps w = - many2one List.filter if in_usa (#loc w) then [in_usa o #loc, not o in_usa o #loc] {loc = #loc w, temp_readings; day = #day w, val it = month = #month w, [[{day=28,hi=74,lo=57,loc="Dallas", year = #year w, month=3,year=2019}, hi = ((#hi w) - 32.0)/1.8, {day=31,hi=31,lo=26,loc="Nome", lo = ((#lo w) - 32.0)/1.8} month=3,year=2019}], else w; [{day=1,hi=11,lo=5,loc="Dublin", month=4,year=2019}, {day=31,hi=21,lo=12,loc="Tunis", 4.4 Row Filter month=3,year=2019}]] Standard ML includes a filter function, so we can still : {day:int, hi:int, lo:int, loc:string, easily select rows which match a predicate. The difficulty month:int, year:int} list list introduced by Standard ML’s type system is that predicates must specify which fields they ignore, not just the fields they inspect, in order to be typable. Standard ML’s ubiquitous currying makes the predicate- 3It also does not work on temp_reports itself due to numeric type incom- list construction functions more syntactically lightweight, patibility, but this is not to be blamed on Standard ML’s record types. for example: Records with Rank Polymorphism ARRAY ’19, June 22, 2019, Phoenix, AZ, USA

- fun split pred table = >>> dallas_temp = {'loc': 'Dallas', 'day': 28, many2one List.filter 'month': 3, 'year': 2019, [pred, not o pred] table; 'hi': 74, 'lo': 57} val split = fn Typical practice for working with tabular data in Python is :('a -> bool) -> 'a list -> 'a list list to not build tables directly from the language’s primitive data - split from_dublin temp_readings; structures but to use the Pandas library [8]. Pandas stores a val it = data frame as a collection of labeled columns, in contrast to [[day=1,hi=11,lo=5,loc="Dublin", previous sections’ use of collections of rows. While the list- month=4,year=2019], of-rows representation is also possible in Python, the sort of [day=28,hi=74,lo=57,loc="Dallas", helper functions needed for emulating rank polymorphism in month=3,year=2019, order to work with that representation fluidly are generally day=31,hi=31,lo=26,loc="Nome", considered non-idiomatic Python. month=3,year=2019, day=31,hi=21,lo=12,loc="Tunis", 5.1 Table Creation month=3,year=2019]] As before, several rows can be packed into a list, and that : {day:int, hi:int, lo:int, loc:string, list can be used to build a Pandas DataFrame object. month:int, year:int} list list >>> temp_list = [dallas_temp, 4.6 Ergonomics {'loc': 'Dublin', 'day': 1, 'month': 4, With first-class record types, it is not strictly necessary to 'year': 2019, 'hi': 11, 'lo': 5}, declare every set of columns we will use in some table. We do {'loc': 'Nome', 'day': 31, 'month': 3, not have to worry about whether a row we want to add into 'year': 2019, 'hi': 31, 'lo': 26}, a table has its fields in the right order because record type {'loc': 'Tunis', 'day': 31, 'month': 3, equality is independent of field ordering. This is in contrast to 'year': 2019, 'hi': 21, 'lo': 12}] Racket-style structs, where all fields are positional (and their >>> by_rows = pd.DataFrame(temp_list) names are only used for projection functions). Functions like Alternatively, a DataFrame can be constructed from a dictio- filter and map still work as expected (though with more nary of columns. needed adapter code than in a rank-polymorphic language) because these are core functional programming tools, not >>> by_cols = something specific to array-oriented languages or libraries. pd.DataFrame({'loc':['Dallas', 'Dublin', However, we lose some of the flexibility that might be 'Nome', 'Tunis'], expected from the set-of-names style of record. Despite hav- 'day': [28, 1, 31, 31], ing first-class record types, Standard ML echoes many of 'month': [3,4,3,3], the ergonomic faults of Racket’s structs. Record types can 'year': 2019, be used without declaration, but we don’t get a liftable con- 'hi': [74, 11, 31, 21], structor function without explicitly writing one. It is also 'lo': [57,5,26,12]}) awkward to build up a task-specific toolkit because field accessor functions cannot be used without somehow speci- 5.2 Column Extraction fying the full set of other field names. Exploratory analysis Since a Pandas DataFrame is kept as a collection of columns, using only a subset of the original columns would require re- extracting one column is a simple indexing operation, with defining filtering, partitioning, and transformation functions no need for implicit or explicit mapping. Each individual originally written for the full table. Many of the problems column is represented as a Series object, which can be con- requiring repetitive per-type solutions could be eliminated sumed using list comprehensions but also has a map method by adding row polymorphism, at the cost of complicating for unary functions. [13]. >>> by_cols['loc'] 5 Candidate 3: Python-style Dictionary 0 Dallas 1 Dublin Python’s structure for labeled heterogeneous data is a dictio- 2 Nome nary mapping strings to field values. Unlike in Racket and 3 Tunis Standard ML, field names themselves are run-time values, allowing arbitrary string computation to choose which field The indexing operator can lift to extract multiple columns to project out. Dictionary construction syntax is similar to by passing a list of column names, producing a DataFrame Standard ML’s record construction. rather than a simple list of Series. ARRAY ’19, June 22, 2019, Phoenix, AZ, USA Justin Slepak, Olin Shivers, and Panagiotis Manolios

>>> by_cols[['loc','hi']] >>> [in_usa(l) for l in by_cols['loc']] loc hi [True, False, True, False] 0 Dallas 74 >>> by_cols[[in_usa(l) for l in 1 Dublin 11 by_cols['loc']]] 2 Nome 31 loc day month year hi lo 3 Tunis 21 0 Dallas 28 3 2019 74 57 2 Nome 31 3 2019 31 26

5.3 Column Update 5.5 Row Partition Dropping in a replacement column requires no lifting, for the Rather than mapping a filter function over multiple pred- same reason as column extraction. A column can also still be icates, Pandas provides a groupby method for splitting up updated by applying a transformation function to each row. DataFrames. The value to use for each row’s group selection This would be a straightforward list comprehension in a table can be a tuple containing some of that row’s fields. As more represented as a list of rows. Otherwise a DataFrame-specific general options, we can pass a function which computes the method must be used with the axis explicitly specified, rather desired group identifier or a list of group identifiers toplace than the language’s native machinery (iterating over a data each row in the corresponding group. Similar to the case in frame itself steps through its columns). #lang remora/dynamic, the same comprehension we used >>> def f2c(f): return (f - 32)/1.8 for filtering can be used for partitioning as well. >>> def normalize_temp(r): >>> us__non_us = ... if in_usa(r['loc']): by_cols.groupby([in_usa(l) for l in ... r['lo'] = f2c(r['lo']) by_cols['loc']]) ... r['hi'] = f2c(r['hi']) >>> us__non_us.get_group(True) ... return r loc day month year hi lo >>> by_cols.apply(normalize_temp, axis=1) 0 Dallas 28 3 2019 74 57 loc day month year hi lo 2 Nome 31 3 2019 31 26 0 Dallas 28 3 2019 23.33 13.89 >>> us__non_us.get_group(False) 1 Dublin 1 4 2019 11.00 5.00 loc day month year hi lo 2 Nome 31 3 2019 -0.56 -3.33 1 Dublin 1 4 2019 11 5 3 Tunis 31 3 2019 21.00 12.00 3 Tunis 31 3 2019 21 12

Both the list-of-dictionaries representation and Pandas 5.6 Ergonomics also offer horizontal concatenation of DataFrames, which would have to be reimplemented for each pair of column The most noticeable benefit to Pandas, over both other sec- sets using Racket structs or Standard ML records. tions’ representations of data frames as well as the list-of- dictionaries option, is the user-friendly output formatting. Annoyingly, using Standard ML records means repeating 5.4 Row Filter the column names in every table row, while using Racket A DataFrame’s loc method can take a boolean mask, similar structs means never showing column names. Standard ML to Remora’s filter function. Our first example from Sec- also tends to silently reorder record fields according to its tion3 of selecting only readings from Dublin can be written own purposes, which may cause related pieces of data to easily by constructing a Series with True in positions corre- print quite far apart from each other. Sticklers about the term sponding to rows where our table’s ’loc’ field is ’Dublin’. “REPL” might find Pandas output unsatisfying, as the printed >>> by_cols['loc'] == 'Dublin' result cannot be read back in as the object it represents. In 0 False principle, even that should be fixable by sprinkling delimiters 1 True in the right places. 2 False The primary drawback of following a Pandas-like design 3 False for records in a rank-polymorphic language is its reliance >>> by_cols[by_cols['loc'] == 'Dublin'] on a large amount of ad hoc machinery. Functionality de- loc day month year hi lo signed for too specific a purpose is more difficult to adapt, 1 Dublin 1 4 2019 11 5 even to closely related tasks. For example, groupby bakes in the assumption that the resulting groups are disjoint and However, the implicit lifting we see for == is special treat- exhaustive. It is awkward to generalize to tasks like selecting ment given only to certain primitives. A user-written predi- several incrementally growing subsets of the data. A rank- cate, like our imagined in_usa check, requires explicit lifting. polymorphic language should already include a good range Records with Rank Polymorphism ARRAY ’19, June 22, 2019, Phoenix, AZ, USA of general-purpose array-manipulating primitives which can • (set L) produces a function which changes the field’s be used on columnar tables in much the same way as on vec- value to a passed-in value, constructing a new record tors, matrices, or higher-dimensional tensors. • (over L) produces a function which updates the In the vein of type-system friendliness, Python’s string- field’s value according to a passed-in unary function, to-anything map structure is not very easy to handle, but also constructing a new record the ability to compute field names via string operations is Most importantly for our purposes, calling view, set, or over probably unneeded flexibility. The ability to concatenate on a record-field lens produces a record-consuming function, data frames both vertically and horizontally can help with which we can then lift over structures of records. This also gathering data from multiple sources, but Racket structs requires some notation for constructing a lens from a field and Standard ML records both make merging heterogeneous name; we will use the syntax (lens fname). While lens is operations difficult. a keyword rather than a function, its result is a function. We also propose syntactic sugar for constructing lens- 6 Synthesis: Manipulating Records with based operations: Lenses • #_(fname ...) expands to Now it is time to come up with a design for record data which (view (compose (lens fname) ...)) accounts for the advantages and disadvantages discussed in • #=(fname ...) expands to the preceding sections. (set (compose (lens fname) ...)) The most important design goal for integrating record • #^(fname ...) expands to types with rank polymorphism is for construction and pro- (over (compose (lens fname) ...)) jection to be first-class functions. The use of special syntax Preserving field order, as opposed to considering a record rather than a constructor function means that a Standard as an unordered collection of labeled values, allows Pandas to ML programmer must manually define such functions in give much more readable output than Standard ML. Records order to get lifting over tables. Python partially sidesteps the with the same types of field values in different orders are problem by emphasizing comprehension syntax, but even isomorphic, but the difference between them is observable this becomes awkward when lifting over several arguments by a user within our intended mode of use. So we conclude with differing frame shapes. Racket’s take on this is farmore that the proper design choice here is to consider records with notationally heavyweight due to having to declare a struct different field orders non-equal. type in order to produce its constructor and projection func- Unlike previous sections, this is a design sketch rather tions. We propose instead a notation for record constructors. than an explication of an existing system. It is not backed In s-expression syntax, a record constructor is written as up by an implementation at this time, so the code presented (record fname1 ... fnamen) below is not executable. The value of a record form is an n-ary function on scalars. 6.1 Table Creation When applied to scalar arguments arg1 through argn, it pro- duces a record equivalent to Standard ML’s > (define dallas-temp {fname = arg , ..., fname = arg } {(loc "Dallas") (day 28) (month 3) 1 1 n n (year 2019) (hi 74) (lo 57)}) or Python’s > (#_(year) dallas-temp) {'fname1' : arg1, ..., 'fnamen' : argn} 2019 Similar notation can even be offered as syntactic sugar with > ([#_(hi) #_(lo)] dallas-temp) [74 57] {(fname1 arg1) ... (fnamen argn)} expanding to Row-based construction of a table works as before. ((record fname1 ... fnamen) arg1 ... argn) > (define temp-readings We also need corresponding notation for projection func- [dallas-temp tions, and ideally update functions. Lenses, arising from {(loc "Dublin") (day 1) (month 4) work by Foster et al [1] and popularized by libraries such (year 2019) (hi 11) (lo 5)} as Haskell’s Control.Lens [7], give a composable way to {(loc "Nome") (day 31) (month 3) access elements of larger structures. Whether used individu- (year 2019) (hi 31) (lo 26)} ally or composed, they are ultimately consumed by functions {(loc "Tunis") (day 31) (month 3) corresponding to what to do at the field in question: (year 2019) (hi 21) (lo 12)}]) • (view L) produces a function which extracts the value Since the “record literal” syntax is actually sugar for func- of the field in its argument associated with the lens L tion application, it can lift over aggregate arguments with ARRAY ’19, June 22, 2019, Phoenix, AZ, USA Justin Slepak, Olin Shivers, and Panagiotis Manolios compatible frames, effectively assembling a data frame from 6.5 Row Partition its columns. As in Section3, we construct and then filter with a frame > (define temp-readings of several boolean masks. The same ways of building arrays {(loc ["Dallas" "Dublin" "Nome" "Tunis"]) of masks work in this setting as well, with struct field (day [28 1 31 31]) accessors replaced by view operations. (month [3 4 3 3]) (year 2019) (hi [74 11 31 21]) 6.6 Ergonomics (lo [57 5 26 12])}) Record construction syntax allows the scalar-level flexibility we had in Standard ML and Python: accessing a field depends 6.2 Column Extraction only on the field name, with no requirement to define a With rank polymorphism, a lens operation lifts from operat- type for the particular collection of names. We also gain the ing on a single record’s field to operating on a column ina advantage we had with Racket structs by treating record data frame—i.e., the corresponding field in every row. notation as syntactic sugar for application of a particular > (#_(loc) temp-readings) function. That function lifts to higher-rank arguments, such ["Dallas" "Dublin" "Nome" "Tunis"] as table columns. This avoids the need to explicitly declare a record-construction function for each collection of fields. > {(loc (#_(loc) temp-readings)) Using lenses to access data within records also avoids (day (#_(day) temp-readings)) the need for Racket’s type-specific field accessor functions. (month (#_(month) temp-readings)) However, eventual efforts to introduce static types must (hi (#_(hi) temp-readings))} allow row polymorphism or run into the same limitations as [{(loc "Dallas") (day 28) (month 3) (hi 74)} Standard ML, where field-manipulating code is tied at the {(loc "Dublin") (day 1) (month 4) (hi 11)} type level to a particular collection of field names. {(loc "Nome") (day 31) (month 3) (hi 31)} Tables with hierarchically nested columns can also be {(loc "Tunis") (day 31) (month 3) (hi 21)}] implemented in all three systems examined above, using a purpose-specific in Pandas or nested structs 6.3 Column Update and records for Racket and Standard ML respectively. How- As with view, the operations set and over generalize from ever, lenses make record nesting much more palatable be- a single record’s field to a data frame’s column. Because field cause a lens for a deeply nested subfield is constructible updates are functions, they are easily composed. using function composition—this is much more lightweight > (define (normalize-temps (w 0)) notation than, for example, chaining field update functions (define new-temp for Racket-style structs. Point-free programming is the tra- (select (in-usa? (#_(loc) w)) f->c id)) ditionally favored style in rank-polymorphic programming ((compose (#^(lo) new-temp) languages, so the use of composable lenses integrates well. (#^(hi) new-temp)) w)) Beyond the Interaction of Orthogonal Features While > (normalize-temps temp-readings) we have been treating heterogeneous record data and rank [{(loc "Dallas") (day 28) (month 3) (year 2019) polymorphism as independent, orthogonal language features, (hi 23.33) (lo 13.89)} there may be something to gain from tighter coupling. For {(loc "Dublin") (day 1) (month 4) (year 2019) a fairly mild example, printing a single record requires in- (hi 11) (lo 5)} cluding all of its field names, but when printing a large array {(loc "Nome") (day 31) (month 3) (year 2019) of records, repeating the field names at each array element (hi -0.56) (lo -3.33)} clutters the user’s view with redundant information. {(loc "Tunis") (day 31) (month 3) (year 2019) A farther-reaching possibility would be allowing records (hi 21) (lo 12)}] to appear in function position, with the record structure forming part of the frame shape. The individual field-cells’ 6.4 Row Filter results would then be assembled into a record of results. We keep the same filter function from Section3. Defining This would allow the column-subset example to be written a boolean mask differs only slightly. The weather-loc field as something like accessor we used when working with Racket structs can be replaced directly with a lens operation #_(loc). > (~(0){#_(loc) #_(day) #_(month) #_(hi)} > ((compose in-usa? #_(loc)) temp-readings) temp-readings) [#t #f #t #f] Records with Rank Polymorphism ARRAY ’19, June 22, 2019, Phoenix, AZ, USA

7 Other Related Work design to include records. Offering a small collection of mutu- In our exploration of how rank polymorphism interacts with ally composable constructs—in our case, rank polymorphism, heterogeneous records, we have chosen three languages’ no- records, and conventional array-manipulation operations— tions of record data with the goal of seeing different sets avoids the need to build and export a large collection of of advantages and disadvantages. Other rank-polymorphic task-specific tools. languages have also taken their own approaches to hetero- geneous data. References Futhark [3] is an array-oriented language for GPU pro- [1] J. Nathan Foster, Michael B. Greenwald, Jonathan T. Moore, Ben- gramming, which includes record types [4]. Record access jamin C. Pierce, and Alan Schmitt. 2007. Combinators for Bidirectional Tree Transformations: A Linguistic Approach to the View-update is a special syntactic form rather than a function, using the Problem. ACM Trans. Program. Lang. Syst. 29, 3, Article 17 (May 2007). same notation for referring to both a field within a record https://doi.org/10.1145/1232420.1232424 and a name exported by a module. [2] Jeremy Gibbons. 2016. APLicative Programming with Naperian Func- R[10] is a domain-specific language for statistical analysis, tors (Extended Abstract). In Proceedings of the 1st International Work- with a data.frame class as a core piece of the language. This shop on Type-Driven Development (TyDe 2016). ACM, New York, NY, USA, 13–14. https://doi.org/10.1145/2976022.2976023 class and its operations served as the inspiration for Pandas [3] Troels Henriksen. 2017. Design and Implementation of the Futhark and several other libraries in a variety of languages. Programming Language. Ph.D. Dissertation. University of Copenhagen, Gibbons demonstrated an embedding of rank polymor- Universitetsparken 5, 2100 København. phism in Haskell via an Applicative type class instance [2]. [4] Troels Henriksen. 2017. Dot Notation for Records. https:// futhark-lang.org/blog/2017-11-11-dot-notation-for-records.html However, Haskell’s record system is historically a sore point [5] Kenneth E. Iverson. 1962. A programming language. John Wiley & in the language’s design, lacking first-class record types and Sons, Inc., New York, NY, USA. even prohibiting types with overlapping field names from [6] Jsoftware, Inc. [n. d.]. The J programming language. http://www. coexisting in the same module. jsoftware.com/ Iverson’s languages APL [5] and J [6] do not include record [7] Edward Kmett. [n. d.]. lens: Lenses, Folds and Traversals. http: //hackage.haskell.org/package/lens types, and this omission is a significant problem in the design [8] Wes McKinney et al. 2010. Data structures for statistical computing in of the language. However, heterogeneous arrays have some python. In Proceedings of the 9th Python in Science Conference, Vol. 445. support. APL allows heterogeneous arrays directly, whereas Austin, TX, 51–56. J requires elements of a heterogeneous array to be boxed. [9] Robin Milner, Mads Tofte, Robert Harper, and David MacQueen. 1997. Either option allows an alist-style implementation of records, The definition of standard ML: revised. MIT press. [10] R Core Team. 2013. R: A Language and Environment for Statistical but heterogeneous data is not the typical programming style Computing. R Foundation for Statistical Computing, Vienna, Austria. in these languages. http://www.R-project.org/ [11] Justin Slepak, Olin Shivers, and Panagiotis Manolios. 2014. An array- 8 Conclusion oriented language with static rank polymorphism. In European Sym- posium on Programming Languages and Systems. Springer, 27–46. We have presented a design for producing and consuming https://doi.org/10.1007/978-3-642-54833-8_3 record data aimed at flexibility in scalar computation. Scalar- [12] Satish Thatte. 1991. A type system for implicit scaling. Sci. Com- level flexibility then leads to easy data frame use whenwe put. Program. 17, 1-3 (Dec. 1991), 217–245. https://doi.org/10.1016/ combine records with rank polymorphism. This investigation 0167-6423(91)90040-5 serves as a useful guide for eventual extension of Remora’s [13] Mitchell Wand. 1991. Type inference for record concatenation and multiple inheritance. Information and Computation 93, 1 (1991), 1–15.