Trends in Functional Programming: 23rd International Symposium, TFP 2022, Virtual Event, March 17–18, 2022, Revised Selected Papers (Lecture Notes in Computer Science) 3031213130, 9783031213137

This book constitutes revised selected papers from the 23rd International Symposium on Trends in Functional Programming,

117 9 6MB

English Pages 204 [200] Year 2023

Report DMCA / Copyright

DOWNLOAD FILE

Polecaj historie

Trends in Functional Programming: 23rd International Symposium, TFP 2022, Virtual Event, March 17–18, 2022, Revised Selected Papers (Lecture Notes in Computer Science)
 3031213130, 9783031213137

Table of contents :
Preface
Organization
Contents
Project Paper: Embedding Generic Monadic Transformer into Scala
1 Introduction
2 Embedding Generic Monadic Cps Transform into Scala
2.1 Monads Parametrization
2.2 Translation of Higher-Order Functions
2.3 Call-Chain Substitutions
2.4 Automatic Coloring
3 Related Work
4 Conclusion and Further Work
References
Towards a Language for Defining Reusable Programming Language Components
1 Introduction
2 CS by Example
2.1 Data Types and Functions
2.2 Effects and Handlers
2.3 Order of Evaluation, Suspension, and Enactment
2.4 Modules and Imports
2.5 Composable Data Types and Functions
3 Defining Reusable Language Components in CS
3.1 A Signature for Reusable Components
3.2 A Language Component for Arithmetic Expressions
3.3 Implementing Functions as a Reusable Effect
3.4 Example Usage
4 Related Work
5 Future Work
6 Conclusion
References
Deep Embedding with Class
1 Introduction
1.1 Research Contribution
2 Deep Embedding
3 Shallow Embedding
3.1 Tagless-Final Embedding
4 Lifting the Backends
5 Existential Data Types
5.1 Unbraiding the Semantics from the Data
6 Transformation Semantics
6.1 Convolution
6.2 Pattern Matching
6.3 Chaining Semantics
7 Generalised Algebraic Data Types
8 Conclusion
9 Related Work
A Appendix
A.1 Data Type Definitions
A.2 Smart Constructors
A.3 Semantics Classes and Data Types
A.4 GDict instances
A.5 Evaluator Instances
A.6 Printer Instances
A.7 Optimisation Instances
References
Understanding Algebraic Effect Handlers via Delimited Control Operators
1 Introduction
2 S0: A Calculus of Shift0 and Dollar
3 h: A Calculus of Effect Handlers
4 Adding and Removing the Return Clause
4.1 Translating Between Dollar and Reset0
4.2 Translating Between Handlers with and Without Return Clause
4.3 Correctness
5 Deriving a CPS Translation
5.1 CPS Translation of Shift0 and Dollar
5.2 CPS Translation of Effect Handlers
5.3 Macro Translation from h to S0
5.4 Composing Macro and CPS Translations
6 Deriving a Type System from the CPS Translation
6.1 Type System of Shift0 and Dollar
6.2 Type System of Effect Handlers
6.3 Applying the CPS Approach
6.4 Soundness
7 Related Work
8 Conclusion and Future Perspectives
References
Reducing the Power Consumption of IoT with Task-Oriented Programming
1 Introduction
1.1 Research Contribution
2 Task-Oriented Programming
2.1 mTask
3 Energy Efficient IoT Nodes
4 Scheduling Tasks Efficiently
4.1 Evaluation Interval
4.2 Basic Refresh Rates
4.3 Deriving Refresh Rates
4.4 User Defined Refresh Rates
5 Running Tasks on Interrupts
6 Implementing Refresh Rates
7 Scheduling Tasks
8 Resulting Power Reductions
8.1 Power Saving
9 Related Work
10 Conclusion
References
Semantic Equivalence of Task-Oriented Programs in TopHat
1 Introduction
2 The TopHat Language
2.1 Editors
2.2 Sequential Composition
2.3 Parallel Composition
2.4 Observations
2.5 Semantics
3 Contextual Equivalence
4 Expression Equivalence
5 Task Conditions
5.1 Failing Tasks
5.2 Finished Tasks
5.3 Stuck Tasks
5.4 Running Tasks
6 Task Equivalence
7 Laws on Tasks
8 Conclusions
A Appendix
A.1 Failing
A.2 Inputs
A.3 Value
References
Algorithm Design with the Selection Monad
1 Introduction
2 Selection Functions
2.1 Pairs of Selection Functions
2.2 Password Example
2.3 Iterated Product of Selection Functions
2.4 Dependently Typed Version
2.5 Extended Password Example
2.6 Selection Functions Form a Monad
2.7 History-Dependent Product of Selection Functions
2.8 Efficiency Drawbacks of This Implementation
3 Greedy Algorithms
4 Examples of Greedy Algorithms
4.1 Password Example
4.2 Prim's Algorithm
4.3 Greedy Graph Walking
5 Correctness
6 Limited Lookahead
6.1 Graph Example
7 Iterated Product
7.1 Examples
8 Conclusion and Future Work
A Proof that Product Equals Sequence
References
Sound and Complete Type Inference for Closed Effect Rows
1 Introduction
2 Motivation
2.1 Algebraic Effects and Handlers
2.2 Naive Hindley-Milner Type Inference with Algebraic Effects and Handlers
2.3 Type Inference with open and close
3 Implicitly Typed Calculus for Algebraic Effects and Handlers
3.1 Syntax
3.2 Declarative Type Inference Rules
4 Syntax-Directed Type Inference Rules
4.1 Type Substitution
4.2 Type Ordering
4.3 Inference Rules with open and close
4.4 Principles on the Use of [let] Rule
4.5 Fragility of [let] Rule
5 Type Inference Algorithm
5.1 Auxiliary Functions
5.2 Unification Algorithm
5.3 Type Inference Algorithm
6 Related Work
7 Conclusion and Future Work
A System F + restrict
B Type-Directed Translation to System F+restrict
References
Towards Efficient Adjustment of Effect Rows
1 Introduction
2 Overview
2.1 Effect Handlers
2.2 Evidence Passing Semantics and Row-Based Effect System
2.3 Effect Type Adjustment for Function Types
2.4 Motivating Open Floating
3 System Fpwo
3.1 Syntax
3.2 Dynamic Semantics
3.3 Static Semantics
3.4 Effect Rows and Closed Prefix Relation
4 Open Floating
4.1 Design of Algorithm
4.2 Organization of Definition
4.3 The Definition
4.4 Example
5 Evaluation
5.1 Ideal Case
5.2 Failure Case
6 Future Work
7 Related Work
8 Conclusion
References
Author Index

Citation preview

LNCS 13401

Wouter Swierstra Nicolas Wu (Eds.)

Trends in Functional Programming 23rd International Symposium, TFP 2022 Virtual Event, March 17–18, 2022 Revised Selected Papers

Lecture Notes in Computer Science Founding Editors Gerhard Goos Karlsruhe Institute of Technology, Karlsruhe, Germany Juris Hartmanis Cornell University, Ithaca, NY, USA

Editorial Board Members Elisa Bertino Purdue University, West Lafayette, IN, USA Wen Gao Peking University, Beijing, China Bernhard Steffen TU Dortmund University, Dortmund, Germany Moti Yung Columbia University, New York, NY, USA

13401

More information about this series at https://link.springer.com/bookseries/558

Wouter Swierstra · Nicolas Wu (Eds.)

Trends in Functional Programming 23rd International Symposium, TFP 2022 Virtual Event, March 17–18, 2022 Revised Selected Papers

Editors Wouter Swierstra Utrecht University Utrecht, The Netherlands

Nicolas Wu Imperial College London, UK

ISSN 0302-9743 ISSN 1611-3349 (electronic) Lecture Notes in Computer Science ISBN 978-3-031-21313-7 ISBN 978-3-031-21314-4 (eBook) https://doi.org/10.1007/978-3-031-21314-4 © Springer Nature Switzerland AG 2022 Chapter “Semantic Equivalence of Task-Oriented Programs in TopHat” is licensed under the terms of the Creative Commons Attribution 4.0 International License (http://creativecommons.org/licenses/by/4.0/). For further details see licence information in the chapter. This work is subject to copyright. All rights are reserved by the Publisher, whether the whole or part of the material is concerned, specifically the rights of translation, reprinting, reuse of illustrations, recitation, broadcasting, reproduction on microfilms or in any other physical way, and transmission or information storage and retrieval, electronic adaptation, computer software, or by similar or dissimilar methodology now known or hereafter developed. The use of general descriptive names, registered names, trademarks, service marks, etc. in this publication does not imply, even in the absence of a specific statement, that such names are exempt from the relevant protective laws and regulations and therefore free for general use. The publisher, the authors, and the editors are safe to assume that the advice and information in this book are believed to be true and accurate at the date of publication. Neither the publisher nor the authors or the editors give a warranty, expressed or implied, with respect to the material contained herein or for any errors or omissions that may have been made. The publisher remains neutral with regard to jurisdictional claims in published maps and institutional affiliations. This Springer imprint is published by the registered company Springer Nature Switzerland AG The registered company address is: Gewerbestrasse 11, 6330 Cham, Switzerland

Preface

This volume contains selected papers presented at the 23rd International Symposium on Trends in Functional Programming (TFP 2022), held online during March 17–18, 2022. The conference was collocated with the 11th International Workshop on Trends in Functional Programming in Education (TFPIE 2022), held online on March 16, 2022. TFP is an international forum for researchers with interests in all aspects of functional programming, taking a broad view of current and future trends in this area. It aspires to be a lively environment for presenting the latest research results and other contributions, with an unconventional reviewing process that allows for full single blind peer review either before or after the symposium (or both, if the pre-symposium reviews ask for changes that need a second review before inclusion in the proceedings). Each paper received three reviews in each round. This year 17 papers were submitted in total, and all of them were presented, together with the keynote by Christiaan Baaij, Co-Founder of QBayLogic, on “Building a Haskell-to-Hardware compiler: Theory & Practice”. After the final reviewing round, revised versions of nine papers were selected for inclusion in this proceedings. All of this was only possible thanks to the hard work of the authors and of the Program Committee members. We are deeply grateful to both. August 2022

Wouter Swierstra Nicolas Wu

Organization

Program Committee Chairs Wouter Swierstra Nicolas Wu Imperial

Utrecht University, Netherlands College London, UK

Program Committee Guillaume Allais José Manuel Calderón Trilla Stephen Chang Matthew Flatt Jeremy Gibbons Zhenjiang Hu Mauro Jaskelioff Moa Johansson Shin-ya Katsumata Oleg Kiselyov Bas Lijnse Kazutaka Matsuda Nico Naus Christine Rizkallah Alejandro Serrano Amir Shaikhha Aaron Stump Baltasar Trancón y Widemann Ningning Xie

University of St Andrews, UK Galois, Inc., USA University of Massachusetts Boston, USA University of Utah, USA University of Oxford, UK Peking University, China CIFASIS/Universidad Nacional de Rosario, Argentina Chalmers University of Technology, Sweden National Institute of Informatics, Japan Tohoku University, Japan Netherlands Defence Academy/Radboud University Nijmegen, Netherlands Tohoku University, Japan Virginia Tech, USA University of New South Wales, Australia Tweag, Netherlands University of Edinburgh, UK University of Iowa, USA Semantics GmbH, Germany University of Cambridge, UK

Contents

Project Paper: Embedding Generic Monadic Transformer into Scala: Can We Merge Monadic Programming into Mainstream? . . . . . . . . . . . . . . . . . . . . . . . . Ruslan Shevchenko

1

Towards a Language for Defining Reusable Programming Language Components (Project Paper) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Cas van der Rest and Casper Bach Poulsen

18

Deep Embedding with Class . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Mart Lubbers

39

Understanding Algebraic Effect Handlers via Delimited Control Operators . . . . Youyou Cong and Kenichi Asai

59

Reducing the Power Consumption of IoT with Task-Oriented Programming . . . . Sjoerd Crooijmans, Mart Lubbers, and Pieter Koopman

80

Semantic Equivalence of Task-Oriented Programs in TopHat . . . . . . . . . . . . . . . . . 100 Tosca Klijnsma and Tim Steenvoorden Algorithm Design with the Selection Monad . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 126 Johannes Hartmann and Jeremy Gibbons Sound and Complete Type Inference for Closed Effect Rows . . . . . . . . . . . . . . . . 144 Kazuki Ikemori, Youyou Cong, Hidehiko Masuhara, and Daan Leijen Towards Efficient Adjustment of Effect Rows . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 169 Naoya Furudono, Youyou Cong, Hidehiko Masuhara, and Daan Leijen Author Index . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 193

Project Paper: Embedding Generic Monadic Transformer into Scala Can We Merge Monadic Programming into Mainstream? Ruslan Shevchenko(B) Institute of Software Systems of National Academy of Sciences of Ukraine, Kyiv, Ukraine [email protected]

Abstract. Dotty-cps-async is an open-source package that consists of scala macro, which implements generic async/await via monadic cps transform, and library, which provides monadic substitutions for higherorder functions from the standard library. It allows developers to use direct control flow constructions of the base language instead of monadic DSL for various applications. Behind well-known async/await operations, the package provides options for transforming higher-order function applications, generating call-chain proxies, and automatic coloring.

1

Introduction

It’s hard to have evidence-based data about industry adoption. From the subjective observations, one of the barriers to industrial adoption of the Scala language is an unnecessary high learning curve. The tradition of using embedded DSL (Hudak [11]) instead of the base language leads to a situation when the ‘cognitive load’ of relatively simple development tasks, such as querying an extra resource, is higher than in mainstream languages. A programmer cannot use control flow constructions of base language but should learn a specific DSL and use a suboptimal embedding of this DSL, usually within monadic for comprehensions. Therefore, developers proficient in Java or TypeScript cannot be immediately proficient in Scala without additional training. Can we provide a development environment that gives the programmer an experience comparable to the state of the art mainstream back-end programming, such as Kotlin structured concurrency, Swift async functions or F# computation expressions ? Dotty-cps-async intends to be an element of the possible answer. It provides the way to embed monadic expressions into base scala language using well-known async/await constructs, existing for nearly all mainstream programming languages. Although the main idea is not new, dotty-cpsasync provides behind well-known interfaces a set of novel features, such as support of the generic monads, transformation of higher-order function applications, generation of call-chain proxies, and automatic coloring. The package is open-source and can be downloaded from the GitHub repository https://github.com/rssh/dotty-cps-async. c Springer Nature Switzerland AG 2022  W. Swierstra and N. Wu (Eds.): TFP 2022, LNCS 13401, pp. 1–17, 2022. https://doi.org/10.1007/978-3-031-21314-4_1

2

R. Shevchenko

The paper is organized as follows: In Sect. 2 we briefly describe the async/await interface and the process of monadification; in Sect. 2.1 add monads parametrization and provide some examples of applying async/await transformation with non-standard monads; in Sect. 2.2 describe a support of using await inside the arguments of higher-order functions, then in Sect. 2.4 intriduce an optional automatic colouring facility and show an example of non-blocking file copying on Sect. 2.1 on page 7 elaborated with coloring on page 14. Short overview on related work in this area is in Sect. 3.

2

Embedding Generic Monadic Cps Transform into Scala

Dotty-cps-async implements an interface similar to scala-async by (Haller [7]) based on optimized monadic CPS transform. It is implemented as Scala macros and provides a simple generic interface with a well-known async/await signature, slightly changed to support monad parametrization. In simplified form this is a combination of two generic pseudofunctions: def async [ F [ _ ] , T ]( f :

T ): F [ T ]

def await [ F [ _ ] , T ]( x : F [ T ]):

T

where we can use await inside the async block . Complete definitions are more complex, although most of this complexity is hidden from the application programmer. Let’s look at it in detail and briefly describe the basics of the Scala 3 language features used in those definitions: Full definition of async : transparent inline def async [ F [ _ ]]( using monad : CpsMonad [ F ]) = InferAsyncArg ( monad ) class InferAsyncArg [ F [ _ ]]( am : CpsMonad [ F ]) { transparent inline def apply [ T ]( f : am . Context ? = > T ): F [ T ] }

– F[_] is a type parameter of async. The compiler will deduce type parameters automatically from context if it is possible. Underscore inside square brackets in F means F is a higher-kinder type with one type parameter. – transparent inline means a macro, which should be expanded during typing phase of the compiler. The compiler passes the typed tree to the macro and refines the output type of the result of the macro application. – using clause at the beginning of the argument list, means that the compiler will substitute the appropriative parameters by the given instance of the parameter type, if such instance is defined in the current scope. Given instance can be introduced in scope via given clause. For example, the following definition will introduce myMonad. as given instance of CpsMonad[Future]. given CpsMonad [ Future ]

= myMonad

Project Paper: Embedding Generic Monadic Transformer into Scala

3

Exists predefined function summon[A] which will give us a given instance of A if it’s defined. – Our async returns an object of the auxiliary class InferAsyncArg that defines apply method. In Scala, any object with the apply method can be applied as if it were a function. The auxiliary class is a work-around for the lack of multiple type parameters in function definitions. The ideal definition, without auxiliaries, would have looked as: transparent inline def async [ F [ _ ]]( using am : CpsMonad [ F ])[ T ]( am . Context ? = > T )

The optimizer should erase the constructor of the intermediate class. – f: (am.Context ?=>> T)] is a context function. Here f is an expression of type T in context am.Context. Inside context function we can use summon[am.Context] to access a context parameter. Context provides an API that can be used only in async block. – am.Context is a path-depended type. Type Context should be defined in the scope of am. When the developer uses async pseudo function in the application code, the compiler will pass to the macro transformation typed tree with expanded implicit parameters and context arguments. An example of original code: async [ Future ] { val ec = summon [ FutureContext ]. executionContext val x = doSomething () .... }

Expansion passed to the macro: async [ Future ]( using FutureAsyncMonad ()). apply { ( fc : FutureContext ) ? = > val ec = fc . executionContext val x = doSomething () ..... }

Here we assume that FutureAsyncMonad. is a given monadic interface for Future where type Context is defined as alias to FutureContext. Monadic operations are defined in the given CpsMonad[F] parameter which should implement the following typeclass: trait CpsMonad [ F [ _ ]] { type Context B ): F [ B ] def flatMap [A , B ]( fa : F [ A ])( f : A = > F [ B ]): F [ B ] }

4

R. Shevchenko

Optionally extended by error generation and handling operations: trait CpsTryMonad [ F [ _ ]] extends CpsMonad [ F ] { def error [ A ]( e : Throwable ): F [ A ] def flatMapTry [A , B ]( fa : F [ A ])( f : Try [ A ] = > F [ B ]): F [ B ] }

The Cps prefix here refers to the relation with continuation passing style for which transformation from direct to monadic style is closely related. This prefix is more related to a library rather than an interface: dotty-cps-async should coexist with generic functional programming frameworks, like cats or scalaz where other forms of Monad are defined, so we need to have another name, to prevent name resolution conflict in application code. Now let us look at the full definition of await: def await [ G [ _ ] ,T , F [ _ ])( x : G [ T ]) ( using CpsMonadConversion [G ,F ] , CpsMonadContext [ F ]): T

Here G. is a type which we awaiting, F – monad which is used in enclosing async. Note that F and G can be different; if the given instance of conversion morphism from G[ ] to F [ ] is defined in the current scope, then await[F] can be used inside async[G]. This morphism is represented by the CpsMonadConversion interface: trait CpsMonadConversion [G [ _ ] , F [ _ ]] { def apply [ T ]( gt : G [ T ]): F [ T ] } CpsMonadContext[F] is an upper bound of CpsMonad[F].Context with one oper-

ation defined: trait CpsMonadContext [ F [ _ ]] { def adoptAwait [ T ]( v : F [ T ]): F [ T ] }

The work of adoptAwait is to pass information from the current monad context into the awaiting monad. For example, this can be a cancellation event in the implementation of a structured concurrency framework. Underlying source transformation is an optimized version of monadification (Ervig, Martin and others [5] ), similar to translating terms into continuations monad (Syme [9]). This translation is limited to the code block inside an async argument. We will use notations F.op as shortcut for appropriative operation over monad typeclass for F . CF code is a translation of code code in the context of F . Let us recap the basic monadification transformations adopted to scala control-flow construction:

Project Paper: Embedding Generic Monadic Transformer into Scala

trivial

CF t where t ia a constant or an identifier F.pure(t)

sequential

CF {a;b} F.f latM ap(CF a)( ⇒CF b)

val definition

CF val a=b; c F.f latM ap(CF a)(a ⇒CF b[a←a ] )

condition

CF if a then b else c F.f latM ap(CF a)(a ⇒if (a ) then CF b else CF c)

match

CF a match{ case r1 ⇒v1 ...rn ⇒vn } F.f latM ap(CF a){a ⇒match{case r1 ⇒CF v1 ...rn ⇒CF vn }}

while

CF while(a){b} whileHelper(CF a,CF b)

where whileHelper is a helper function, defined as def whileHelper ( cond : F [ Boolean ] , body : F [ Unit ]): F [ Unit ] = F . flatMap ( cond ){ c = > if ( c ) { F . flatMap ( body ){ _ = > whileHelper ( cond , body ) } } else { F . pure (()) } } CF try{a}{catch e⇒b}{f inally c}

try/catch

F.f latM ap( F.f latM apT ry(CF a){ case Success(v) => F.pure(v) case F ailure(e) => CF b } ){x ⇒ F.map(CF c, x)}

throw

CF throw ex F.error(ex)

lambda

CF a⇒b a⇒CF b .

application

CF f (a) where a is non-functional type F.f latM ap(CF a)(x=>f (x)) CF f (a)where f is a lamba-function. F.f latM ap(CF a)(x=>CF f (x)) CF f (a) where ∃f  is an external-provided shifted variant of f F.f latM ap(CF a)(x=>f  (x))

await

CF awaitG (a)(m,c) where F =G c.adoptAwait(a) CF awaitG (a)(m,c) where F =G c.adoptAwait(CpsM onadConversion[F,G](a))

5

6

R. Shevchenko

The mechanism for definition and substitution of shifted functions is described in Sect. 2.2. Implementation differs from the basic transformation, by few optimizations, which are direct applications of unit monad law: – few sequential blocks with trivial CPS transformations are merged into one: F.f latM ap(F.pure(a))(x ⇒ F.pure(b(x)) F.pure(b(a)) – translation of control flow operators are specializated for the cases when transformations of some subterms are trivial. In the best case, control-flow construction is lifted inside monad barriers. For example, the transformation rules for if-then-else taking into account optimizations will look like: CF if a then b else c,CF a=F.pure(a)∧CF b=F.pure(b)∧CF c=F.pure(c) F.f latM ap(CF a)(v=>if (v)then CF b else CF c) CF if a then b else c,CF a=F.pure(a)∧(CF b=F.pure(b)∨CF c=F.pure(c)) if a then CF b else CF c CF if a then b else c,CF a=F.pure(a)∧CF b=F.pure(b)∧CF c=F.pure(c) F.pure(if a then b else c)

Such specializations are defined for each control-flow construction. In the resulting code, the number of monadic binds is usually the same as a number of awaits in the program, which made performance characteristics of code, written in a direct style and then transformed to the monadic style, the same, as the code manually written by hand in monadic style. 2.1

Monads Parametrization

Async expressions are parameterized by monads, which allows the CPS macro to support behind the standard case of asynchronous processing other more exotic applications, such as processing effects( Syme [19]), (Brachth¨ auser [2]), logical search (Kiselyov and Shan [12]), or probabilistic programming (Adam and Ghahramani and others [1]). Potentially, list of problem domains from F# computation expression Zoo (Tomas and Syme [14]) is directly applicable to dotty-cps-async. In practice, most used monads constructed over Future, effect wrappers, like IO and constructions over effect classes extended by additional custom logic. Let’s look at the following example: val prg = async [[ X ] = > > Resource [ IO , X ]] { val input = await ( open ( Paths . get ( inputName ) , READ )) val output = await ( open ( outputName , WRITE , CREATE , T R U N C A T E _ E X I S T I N G )) var nBytes = 0 while

Project Paper: Embedding Generic Monadic Transformer into Scala

7

val buffer = await ( read ( input , BUF_SIZE )) val cBytes = buffer . position () await ( write ( output , buffer )) nBytes += cBytes cBytes == BUF_SIZE do () nBytes }

Here [X] =>> Resource[IO,X] is a type-lambda, which represent the computational effect monad IO, extended by the acquisition and release of resources of type of the argument X. Inside async[[X] =>> Resource[IO,X]], input and output. resources will be automatically closed at the end of the appropriative scope. Without async/await, one would have to write the following: ( for { i n p u t val c B y t e s = b u f f e r . p o s i t i o n ( ) => w r i t e ( output , b u f f e r ) . flatMap { nBytes += c B y t e s i f ( c B y t e s == BUFF SIZE ) step () else IO . pure ( ( ) ) } } } => nBytes } s t e p ( ) . map{ }

The next example illustrate a monadic representation of combinatorial search. Monad [X] =>> ReadChannel[Future,X] represent a CSP[Communicating Sequential Processes]-like channel Hoare [10], where monadic combinators apply the functions over the stream of possible states. We want to solve the classical N-Queens puzzle: placing N queens on a chessboard so that no two figures threaten each other. Let us represent the chessboard state as a vector of queens second coordinates queens with additional helper method isUnderAttack with obvious semantics. The i-th queen is situated at (i, queens[i]) location.

8

R. Shevchenko type State = Vector [ Int ] extension ( queens : State ) { def isUnderAttack ( i : Int , j : Int ): Boolean = queens . zipWithIndex . exists { ( qj , qi ) = > qi == i || qj == j || i - j == qi - qj || i + j == qi + qj } def asPairs : Vector [( Int , Int )] = queens . zipWithIndex . map ( _ . swap ) }

Function putQueen. generate from one starting state a channel of possible next states. async[Future] in putQueen spawns a concurrent process for enumerating the next possible steps in N-Queens solution. def putQueen ( state : State ): ReadChannel [ Future , State ] = val ch = makeChannel [ State ]() async [ Future ] { val i = state . length if i < N then for { j > ReadChannel [ Future , X ]] { if ( state . length < N ) then val nextState = await ( putQueen ( state )) await ( solutions ( nextState )) else state }

(runnable version is available at https://github.com/rssh/scala-gopher/blob/ master/shared/src/test/scala/gopher/monads/Queens.scala). The computation is directed by reading from the stream of solutions. In putQueen, the computations inside the loop will suspend after each write to the output channel, until all descendent states are explored. The suspension point await is hidden inside ch.write inside for loop. ch.write is defined in ReadChannel[F,A] as transparent inline def write ( inline a : A ): Unit = await ( awrite ( a ))( using CpsMonad [ F ])

Project Paper: Embedding Generic Monadic Transformer into Scala

9

transparent inline macros in scala are expanded in code at the same compiler phase before enclosing macro, so async code transformer process this expression in for loop instead ch.write. In such way solutions(State.empty).take(2) will return the first two solutions without performing a breadth-first search. 2.2

Translation of Higher-Order Functions

Supporting cps-transformation of higher-order functions is important for a functional language because it allows await expression inside loops and arguments of common collection operators. As an example, in the previous section await inside for loop was used for asynchronous channel write. Using await inside higher-order function enables idiomatic functional style, such as val v = cache . getOrElse ( url , await fetch ( url ))

Local cps transform changes the type of a lambda function. If the runtime platform supports continuations, we can keep the shape of the arguments in the application unchanged by defining ‘monad-escape’ function transformers, which can restore the view of cps(f ) : A ⇒ F [B] back to A → B. However, for the platform without continuation support, higher-order functions from other module is a barrier for local async transformations. For the time of the writing of this article, none of the available Scala runtimes (i.e. JVM, Js, or Native) have continuations support. For JVM exists a plan to implement continuations support via Project Loom [13], but it is not available for production use yet. JavaScript runtime is significantly smaller than JVM, and runtime semantics is precisely defined asynchronous. For those runtimes and for cases when semantic of monad does not allow us to build such escape function, dotty-cps-async implements limited support of higher-order functions. Macro performs a set of transformations, which allows developers to describe the substitution for the origin higher-order function in their code. Let us have a first-order function: f : A ⇒ B which have form λx : codef (x) and higher-order method o.m : (A ⇒ B) ⇒ C.. For simplicity, let’s assume that o is reference to external symbol and not need cps-transformation itself, since we want to show only function call transaltions here. Async transformation transform code : X into CF [code] : F [X], where F is our monad.

10

R. Shevchenko

Let us informally describe a set of transformations used to translate function call: unchanged

CF o.m(f ),CF (codef )==F.pure(codef ) F.pure(o.m(f ))

monadic

Cf o.m(f ),B=F [B  ] F.pure(o.m(λx:A⇒CpsM onad[F ].f latM ap(CF codef ))(identity) CF o.m(f ), ∃asyncShif tO =summon[AsyncShif t[O]]:

asyncShift-fo

asyncShift-o

asyncShif tO .m:O×CpsM onad[F ]⇒(A⇒F [B])⇒F [C] asyncShif tO .m(monad,o)(x⇒CF codef (x)

CF o.m(f ), ∃asyncShif tO = summon[AsyncShif t[O]] : asyncShif tO .m : O ⇒ (A ⇒ F [B]) ⇒ F [C] asyncShif tO .m(o)(x⇒CF codef (x)

inplace-f

.Cf om (f ),∃masyncShif t ∈methods(O):CpsM onad[F ]⇒(A⇒F [B])⇒F [C] masyncShif t (CF codef (x))

inplace

.Cf om (f ),∃masyncShif t ∈methods(O):(A⇒F [B])⇒F [C] masyncShif t (CF codef (x))

Explanation: – In unchanged case we can leave the call unchanged because no cps transformation was needed. Note, that this handle a case when we have no acccess to the source of the f argument: if x is defined externally it can’t contains await. – In monadic case is possible to reshape function arguments, to keep the same signature to receive. – case asyncShift-fo define call substitution: If we have instance asyncShif tO of marker typeclass AsyncShift[O], which provide a substitution methods. mshif t with additional parameter list where we pass original object and target monad. —.e. let we have class with higher-order function, for example: class Cache [K , V ] { def getOrUpdate ( k : K , whenAbsent : }

= > V ): V

and want to use this class in asynchronous environment like next code fragment: async [ Future ] { ... cache . getOrUpdate (k , await ( fetchValue ( k ))) }

where fetchValue return Future[V]. For defined async substitution for getOrUpdage method we should define a given instance of marker typeclass asyncShif tCache when shifted method is defined.

Project Paper: Embedding Generic Monadic Transformer into Scala

11

class CacheAsyncShift [K , V ] extends AsyncShift [ Cache [K , V ]]{ def getOrUpdate [ F [ _ ]]( o : Cache [K , V ] , m : CpsMonad [ F ]) ( k :K , whenAbsent : () = > F [ V ]): F [ V ] = .... } given CacheAsyncShift [K , V ]()

Here substitution method have one additional list of arguments (o:Cache[K,V],m:CpsMonad[F]), where we pass original object itself and our target monad. Since monad parameter is generic, we also have additional type parameter F. Functional call will be transformed to summon [ AsyncCache [ Cache [K , V ]]] . getOrUpdate [ Future ]( cache , monad )( k , () = > fetchValue ( k ))

and summon[AsyncCache[Cache[K,V]]] will be resolved to CacheAsyncShift[K,V] by implicit resolution rules, so resulting expression will be CacheAsyncShift [K , V ]() . getOrUpdate [ Future ]( cache , monad )( k , () = > fetchValue ( k ))

– case asyncShift-o is a modification of the previous rule for the situation when our target monad already parametrizes the substitution class, so we do not need an extra type parameter and monad instance in the additional parameter list. – case inplace-f and inplace describe a situation when the author of a class is aware of the existence of dotty-cps-async and define a shifted method in the same scope as the original method. By convention, such shifted methods are prefixed with Async suffix. Example: class

Cache [K , V ]

{

def getOrUpdate ( k : K , whenAbsent :

= > V ): V

def getOrUpdateAsync [ F [ _ ]]( m : CpsMonad [ F ]) ( k : K , whenAbsent : () = > F [ V ]): F [ V ] }

Such substitutors for most higher-order functions from Scala standard library are supplied with dotty-cps-async runtime. Also, developers can provide their substitution for third-party libraries. The return type of substituted function can be: – C, the same as the origin – F [C] origin return type wrapped into the monad. – CallChainAsyncShiftSubst[F,C,F[C]]. This is a special marker interface for call chain substitution, which wich will be described later.

12

R. Shevchenko

– Exists method in O with name m_async or mAsync which accept shifted argument f : A ⇒ F [B]. The conventions for the return type are the same as in the previous case. This case is helpful for the fluent development of API, which is accessible in both synchronous and asynchronous forms. – If none of the above is satisfied, the macro generates a compile-time error. These rules are extended to multiple parameters and multiple parameters list, assuming that if we have one higher-order async parameter, then all other parameters should also be transformed, having only one representation of the asynchronous method. Functions with order more than two can be supported if we will extend typing rules for shifted functions arguments: argument of type A → B → C will have asynchronous form with type A → F [B → F [C]]. 2.3

Call-Chain Substitutions

As shown in previous section, one of the possible variant of return method of substituted higher-order function is CallChainAsyncShiftSubst[F[_],B,F[B]]. The developer can use this variant to delay applying F [ ] till the end of the call chain. For example, let’s look at the next block of code: for { url limit ) } yield await ( fetchData ( url )

wich is desugared as urls . withFilter ( url = > await ( score ( url )) > limit ). map ( url = > await ( fetchData ))

The programmer expects that the behavior of the code should be the same, regardless of using await inside a loop, so the list of URL-s will be iterated once. However, if the result of withFilter has form F[List.WithFilter], two iterations are performed - one for filtering the list of URLs and the other over the filtered list to perform fetching data. User objects for call-chain substitution can accumulate the sequence of higher-order functions in one batch and perform iteration once. This block of code is transformed as follows: summon [ AsyncShift [ List [ String ]]. withFilter [ F ]( urls , m )( url = > m . map ( score ( url ))( x = >x > limit ) ) // C a l l C h a i n A s y n c S h i f t S u b s t [F , WithFilter , F [ A ]] . mapAsync ( url = > fetchData ) // function added to builder . _finishChain () // finally eval all .

Project Paper: Embedding Generic Monadic Transformer into Scala

2.4

13

Automatic Coloring

Automatic coloring is the way to free the developer from writing boilerplate await statements. Since most industrial code is built with some asynchronous framework, await expressions are often situated literally in each line. Those expressions do not carry out business logic; when writing code, we should not care how an object is coming to code, synchronously or asynchronously, the same as we do not care how memory to our objects should be allocated and deallocated. Apart from performance-critical applications (e.g., developing a web server for which the developer requires complete control of low-level concurrency details), it is preferable to use higher-level constructs hiding low-level details. . When writing business logic using some low-level system framework, we expect that framework provides a reasonable generic concurrency model and abstracts away from manual coloring. We can provide implicit conversion from F [T ] to T . Can we make such conversion safe and preserve semantics with automatic coloring? It is safe when F [ ] is a Future, because multiple calls of await on Future produce the same effect as one call – after the result value will be available after first await, it will be returned immediately after other calls of awaits of the same Future. i.e., we can say, that Future is cached. For other types of monads, where each await can perform a new computation, such implicit conversion will be unsafe - the behavor of the following code snippets will be different: val a = x f1 ( await ( a )) f2 ( await ( a ))

and val a = await ( x ) f1 ( a ) f2 ( a )

To overcome this, we can provide memoization of execution, by embedding the memoization into the transformation of val definitions. Let us have block of code { val v = expr; tailv }, expr return value of type F [T ] and exists CpsMemoization[F] with method apply[T](F[T]):F[F[T]]. Cps transformer can check the variable type and rewrite this to. summon[CpsMonad[F]].flatMap(CpsMemoization[F](expr))(v1 ⇒ cps(tailv1 )) Implicit conversions are often criticized as an unsafe technique, which can be a source of bugs and maintainability problems. In our case, uncontrolled usage of implicit conversions can break the semantics of building complex effects, where some building parts can be automatically memoized. Dotty-cps-async implements preliminary analysis of automatically generated conversion, which emits errors when detecting potentially unsafe usage.

14

R. Shevchenko

To make transformation safe, we should check that developer cannot pass memoized value to API, which expects a delayed effect. Preliminary analysis ensures that all usages of memoized values are in synchronous context by forcing the next rules: – If some variable is used only in a synchronous context (i.e., via await), the macro will color it as synchronous (i.e., cached if used more than once). – If some variable is passed to other functions as an effect - it is colored as asynchronous (i.e., uncached). – If the variable is simultaneously used in synchronous and asynchronous contexts, we cannot deduce the programmer’s intention, and the coloring macro will report an error. – If the variable, defined outside of the async block, is used in synchronous context more than once - the macro also will report an error. Behind providing implicit conversion, automatic coloring should also care about value discarding: expressions that provide only side-effects are not an assignment to some value but discarded. When we do automatic coloring, the monad with side-effect generation becomes the value of an expression. So, we should also transform statements with value discard to insert awaits there. Dotty-cps-async interfaces has a ValueDiscard[T] typeclass. The statement inside async block can discard value of type T only if exists implementation of ValueDiscard[T] interfaces: in such case macro transforms value discard into summon[ValueDiscard[T]].discard(t). A special marker typeclass AwaitValueDiscard[F[T]] is used when this value discard should be a call to await. If we will apply automatic coloring to our example with copying file, we will see that difference between synchronous and asynchronous code become invisible. val prg = async [[ X ] = > > Resource [ IO , X ]] { val input = open ( Paths . get ( inputName ) , READ ) val output = open ( outputName , WRITE , CREATE , T R U N C A T E _ E X I S T I N G ) var nBytes = 0 while val buffer = read ( input , BUF_SIZE ) val cBytes = buffer . position () write ( output , buffer ) nBytes += cBytes cBytes == BUF_SIZE do () nBytes }

3

Related Work

The idea of ‘virtual’ program flow encapsulated in a monad is tracked to (Claessen [4]), which become a foundation for Haskell concurrent library. Later

Project Paper: Embedding Generic Monadic Transformer into Scala

15

F# computation expressions were implemented as further development of donotation. Furthermore, C# moves async/await from virtual monadic control-flow to ‘normal control-flow’, which becomes a pattern for other languages (Syme [18]). (Tomas and Syme [14]) provides an overview of computation expression usage in different areas. Generic monadic operation pairs [reify/reflect] and links between monadic and cps transformations are described in (Filinski [6]). In scala land, the first cps transformer was implemented as a compiler plugin (Rompf, Maier, Odersky ) [17]. It provides quite a powerful but complex interface based on delimited continuations. Scala-Async (Haller [7]) provides a more familiar interface for developers for organizing asynchronous processing by compling async control flow to state machines. The main limitation is the absence of exception handling. Recently, a Lightbend team moved implementation of scala-async from macro to compiler plugin and extended one to support external ’Future systems’ such as IO or Monix. Although dotty-cps-async is internally based on another type of transformation, it can be viewed as an extension of the scala-async interface for the next language version with a similar role in the Scala ecosystem. The new facilities are a generic monad interface, support of try/catch, and limited support for higher-order functions. In (Haller and Miller [8]) scala-async model is extended to handle reactive streams. Scala coroutines (Prokopec and Fengyun [15]) provides a model which allows to build async/await interface on top of coroutines. Scala Virtualized (Rompf, Amin, Moors, Haller [16]) devotes to solving a more general problem: providing deep embedding not only for monadic costructions but for arbitrary language. Scala Effekt ( Brachth¨ auser and others [2]) allows interpretation of effect handlers inside control monad with delimited continuations. The same authors released a monadic reflection library for scala3 ( Brachth¨ auser and others [3]), using the capabilities of yet not released support for continuations in JVM in (Project Loom [13]). This approach can be a convenient way for implementing async/await like functionality for the future versions of the JVM, for monads, which can be implemented on top of the one-shot continuations. Note, that example of combinatorial search from Sect. 2.1 on page 7, cannot be implemented with the runtime monadic because combinatorial search, as other applications of non-determenism requires multiple-shot continuations, where captured continuations can be invoked more than once.

4

Conclusion and Further Work

Prerelease versions of dotty-cps-async have been available as open-source for more than a year, and we have some information based on actual usage in application projects. Macro library is used in open-source chatbot server with Future based stack. An experimental proofspace.id internal microservice for connecting PostgreSQL database to VoIP server is built with the help of dottycps-async with cats-effect stack. The most frequently used monad here is [X] =>> Resource[IO,X] (the common pattern is to acquire database connection

16

R. Shevchenko

from the pool for each request). Also, we can point to the port of scala-gopher to scala3, which provides an implementation of concurrent sequential process primitives on top of generic monadic API and experimental TypeLevel project, which brings the direct style into the cats-effect ecosystem. Overall feedback is mostly positive. Reported issues usually have form of inability to compile some specific tree and often tracked down to the issues in the compiler. Note, that scala3 compiler also was higthly experimental at this time. The number of reports about ergonomic and diagnostics is relative low. Particulary this is because scala3 macros is applied after typing, so usually type errors are catched before macro applications. In some cases we have an error during retyping of transformed tree: for this case the path for better error diagnostics was submitted and merged into scala3 compiler. The work of the patch is extending error message by showing the tree, which was transformed by current macros, in addition to the code position, when -explain compiler option is enabled. Performance issues are not reported at all. Dotty-cps-async does not provide its own asynchronous runtime but is used with some existing runtime. Furthermore, if synchronous code remains unchanged and asynchronous code does not add extra runtime operations, then any benchmark will show a performance of the underlying runtime. Ability to use direct control-flow on top of some library is a one half of programming experience. The other part is the library itself. Currently, we have a set of asynchronous scala runtimes with a different sets of capabilities and it would be interesting to build some uniform facilities for concurrency programming. One of the open questions is to extend eager Future runtime to support structured concurrency; Problem from the other side – users of effect stacks, such as IO, need to wrap impure API into effects. Can we automate this process? Also it will be interesting to adopt using of runtime continuations instead compile-time transformations for some type of monads, which will eliminate the need to manually write substitutions for higher-order functions on continuationenabled platforms. Another direction is the expressivity of internal language, which can be extended by building appropriate wrapper control monad.

References 1. Adam, Ghahramani, Z., Gordon, A.D.: Practical probabilistic programming with monads. SIGPLAN Not. 50(12), 165–176 (2015). https://doi.org/10.1145/ 2887747.2804317 2. Brachth¨ auser, J.I., Schuster, P., Ostermann, K.: Effekt: capability-passing style for type- and effect-safe, extensible effect handlers in scala. J. Funct. Program. 30, e8 (2020). https://doi.org/10.1017/S0956796820000027 3. Brachth¨ auser, J., Boruch-Gruszecki, A., Odersky, M.: Representing monads with capabilities. Technical report, Higher-Order Programming with Effects (HOPE) (2021). https://github.com/lampepfl/monadic-reflection 4. Claessen, K.: A poor man’s concurrency monad. J. Funct. Program. 9(3), 313–323 (1999). https://doi.org/10.1017/S0956796899003342

Project Paper: Embedding Generic Monadic Transformer into Scala

17

5. Erwig, M., Ren, D.: Monadification of functional programs. Sci. Comput. Program. 52(1–3), 101–129 (2004). https://doi.org/10.1016/j.scico.2004.03.004 6. Filinski, A.: Representing monads. In: Proceedings of the 21st ACM SIGPLANSIGACT Symposium on Principles of Programming Languages, POPL 1994, Association for Computing Machinery, New York, pp. 446–457 (1994). https://doi.org/ 10.1145/174675.178047 7. Haller, P., Lightbend: scala-async (2013). https://github.com/scala-async/scalaasync 8. Haller, P., Miller, H.: A formal model for direct-style asynchronous observables. CoRR abs/1511.00511 (2015). http://arxiv.org/abs/1511.00511 9. Hatcliff, J., Danvy, O.: A generic account of continuation-passing styles. In: Proceedings of the 21st ACM SIGPLAN-SIGACT Symposium on Principles of Programming Languages, POPL 1994, Association for Computing Machinery, New York, pp. 458–471 (1994). https://doi.org/10.1145/174675.178053 10. Hoare, C.: Communicating Sequential Processes. Prentice-Hall International Series in Computer Science, Prentice Hall (1985). http://www.usingcsp.com/cspbook.pdf 11. Hudak, P.: Building domain-specific embedded languages. ACM Comput. Surv. 28(4es), 196-es (1996). https://doi.org/10.1145/242224.242477 12. Kiselyov, O., Shan, C.c., Friedman, D.P., Sabry, A.: Backtracking, interleaving, and terminating monad transformers: (functional pearl). SIGPLAN Not. 40(9), 192–203 (2005). https://doi.org/10.1145/1090189.1086390 13. Oracle: Project loom (2018–2022). https://openjdk.java.net/projects/loom/ 14. Petricek, T., Syme, D.: The F# computation expression zoo. In: Proceedings of Practical Aspects of Declarative Languages. PADL 2014 (2014) 15. Prokopec, A., Liu, F.: Theory and practice of coroutines with snapshots. In: Millstein, T. (ed.) 32nd European Conference on Object-Oriented Programming (ECOOP 2018). Leibniz International Proceedings in Informatics (LIPIcs), vol. 109, pp. 3:1–3:32. Schloss Dagstuhl-Leibniz-Zentrum fuer Informatik, Dagstuhl, Germany (2018). https://doi.org/10.4230/LIPIcs.ECOOP.2018.3, http://drops. dagstuhl.de/opus/volltexte/2018/9208 16. Rompf, T., Amin, N., Moors, A., Haller, P., Odersky, M.: Scala-virtualized: linguistic reuse for deep embeddings. High.-Order Symbolic Comput. 25(1), 165–207 (2013). https://doi.org/10.1007/s10990-013-9096-9 17. Rompf, T., Maier, I., Odersky, M.: Implementing first-class polymorphic delimited continuations by a type-directed selective CPS-transform. In: Hutton, G., Tolmach, A.P. (eds.) Proceeding of the 14th ACM SIGPLAN International Conference on Functional programming, ICFP 2009, Edinburgh, Scotland, UK, 31 August–2 September, 2009, pp. 317–328. ACM (2009). https://doi.org/10.1145/ 1596550.1596596 18. Syme, D.: The early history of F#. Proc. ACM Program. Lang. 4(HOPL) (2020). https://doi.org/10.1145/3386325 19. Wadler, P., Thiemann, P.: The marriage of effects and monads. ACM Trans. Comput. Logic 4(1), 1–32 (Jan 2003). https://doi.org/10.1145/601775.601776

Towards a Language for Defining Reusable Programming Language Components (Project Paper) Cas van der Rest(B) and Casper Bach Poulsen Delft University of Technology, Delft, The Netherlands {c.r.vanderrest,c.b.poulsen}@tudelft.nl

Abstract. Developing programming languages is a difficult task that requires a lot of time, effort, and expertise. Reusable programming language components make this task easier, by allowing language designers to grab off-the-shelf components for common language features. Modern functional programming languages, however, lack support for reuse of definitions, and thus language components defined using algebraic data types and pattern matching functions cannot be reused without modifying or copying existing code. To improve the situation, we introduce CS, a functional meta-language for developing reusable programming language components, that features built-in support for extensible data types and functions, as well as effects and handlers. In CS, we can define language components using algebraic data types and pattern matching functions, such that we can compose language components into larger languages and define new interpretations for existing components without modifying existing definitions.

1

Introduction

Developing programming languages is a difficult and time consuming task, which requires a lot of expertise from the language designer. One way to reduce the cost of language development is to build languages from reusable programming components, allowing language designers to grab off-the-shelf components for common language features. This approach has the potential to make language development both cheaper and more accesbile, while producing specifications that allow us to understand the semantics of language features independent from the language they are a part of. Modern functional programming languages, however, lack support for reuse of definitions, and as a result, language components built from algebraic data types and pattern matching functions cannot be reused without modifying or copying existing code. To illustrate the kind of language components we would like to define modularly, and where current functional

c Springer Nature Switzerland AG 2022  W. Swierstra and N. Wu (Eds.): TFP 2022, LNCS 13401, pp. 18–38, 2022. https://doi.org/10.1007/978-3-031-21314-4_2

Towards a Language for Defining Resuable Language Components

19

programming languages fall short for this purpose, we consider the implementation of a tiny expression language and its interpreter in Haskell: data Expr = Lit Int | Div Expr Expr eval :: MonadFail m ⇒ Expr → m Int eval (Lit x ) = return x eval (Div e1 e2 ) = do v1 ← eval e1 v2 ← eval e2 if v2 ≡ 0 then return (v1 ‘div‘ v2 else fail The Expr data type declares an abstract syntax type with constructors for literals and division, and the function eval implements an interpreter for Expr . Importantly, eval needs to account for possible divisions by zero: evaluating Div (Lit 10) (Lit 0), for example, should safely evaluate to a result that indicates a failure, without crashing. For this reason eval does not produce an Int directly, but rather wraps its result in an abstract monad m that encapsulates the side effects of interpretation. In this case, we only assume that m is a member of the MonadFail typeclass. The MonadFail class hase one function, fail : class MonadFail m where fail :: m a We refer to functions, such as fail , that allow us to interact with an abstract monad as operations. We choose to factor language definitions this way, because it allows us to both define a completely new interpretation such as pretty printing or compilation for Expr by writing new functions pretty :: Expr → String or compile :: Expr → m [Instr ], while also having the option to change the implementation of existing semantics, by supplying alternative implementations for the fail operation. We can summarize this approach to defining language components with the following pipeline: denotation

implementation

Syntax −−−−−−−→ Operations −−−−−−−−−−→ Result That is, a denotation maps syntax to an appropriate domain. In the definition of this domain, we distinguish between the type of the resulting value, and the side effects of computing this result, which are encapsulated in an abstract monad. We interact with this abstract monad using operations, and thus to extract a result we must supply a monad that implements all required operations. What if we want to extend this language? To add new constructors to the abstract syntax tree, we must extend the definition of Expr , and modify all functions that match on Expr accordingly. Furthermore, the new clauses for these constructors may impose additional requirements on m for which we would need to add more typeclass constraints, and any existing instantiations of m would need to be updated to ensure that they are still a member of all required typeclasses.

20

C. van der Rest and C. B. Poulsen

Clearly, for these reasons Expr and eval in their current form do not work very well as a reusable language component. We introduce CS, a functional meta-language for defining reusable programming language components. The goal of CS is to provide a language in which one can define language components by defining data types and pattern matching functions, like Expr and eval , in such a way that we compose the syntax, interpretations, and effects of a language component without affecting existing defintion. Importantly, we should also retain the possibility to add completely new interpretations for existing syntax by writing a new pattern matching function. In other words, CS should solve the expression problem [34]. We can summarize this with following concrete design goals. In CS, one should be able to – extend existing abstract syntax types with new constructors without having to modify existing definitions, – extend existing denotations with clauses for new constructors, and define new semantics for existing syntax by defining new denotations, – define abstract effect operations, and use these operations to implement denotation clauses without having to worry about the operations needed by other clauses, and – define implementations for effect operations that are independent from the implementations of other operations. There exist abstractions, such as Data Types a ` la Carte [33] and Algebraic Effects and Handlers [26], that achieve the same goals. These provide the wellunderstood formalism on wich CS is built. CS then provides a convenient surface syntax for working with these abstractions that avoids the overhead that occurs when encoding them in a host language like Haskell. CS is work in progress. There is a prototype implementation of an interpreter and interactive programming environment which we can use to define and run the examples from this abstract. We are, however, still in the process of developing and implementing a type system. In particular, we should statically detect errors resulting from missing implementations of function clauses. The name CS is an abbreviation of “CompositionalSemantics”. It is also the initials of Christopher Strachey, whose pioneering work [31] initiated the development of denotational semantics. In Fundamental Concepts in Programming Languages [32], Strachey wrote that the urgent task in programming languages is to explore the field of semantic possibilities, and that we need to recognize and isolate the central concepts of programming languages. Today, five decades later, the words still ring true. The CS language aims to address this urgent task in programming languages, by supporting the definition of reusable (central) programming language concepts, via compositional denotation functions that map the syntax of programming languages to their meaning.

Towards a Language for Defining Resuable Language Components

2

21

CS by Example

In this section, we give an example-driven introduction to CS’s features. 2.1

Data Types and Functions

CS is a functional programming language, and comes equipped with algebraic data types and pattern matching functions. We declare a new inductive data type for natural numbers as follows: data Nat = Zero | Suc Nat We can write functions over inductive data types by pattern matching, using a “pipe” (|) symbol to separate clauses: fun double : Nat → Nat where | Zero → Zero | (Suc n) → Suc (Suc (double n)) Not all types are user-declared: CS also offers built-in types and syntax for integers, lists, tuples, and strings. fun length : List a → Int where | [] → 0 | ( :: xs) → 1 + length xs fun zip : List a → List b → List (a ∗ b) where → [ ] | [] | (x :: xs) (y :: ys) → (x , y) :: zip xs ys Both length and zip are polymorphic in the type of elements stored in the input list(s). Functions implicitly generalize over any free variables in their type signature. 2.2

Effects and Handlers

CS supports effectful programs by means of effects and handlers in the spirit of Plotkin and Pretnar [26], adapted to support higher-order operations. The key idea of the effects-and-handlers approach is to declare the syntax of effectful operations, and assign a semantics to these operations in a separate handler. Programs compute values and have side-effects, and operations act as the interface through which these side effects are triggered. We declare a new effect Fail with a single operation fail in CS as follows: effect Fail where fail : {[Fail ] a } Effects in CS are declared with the effect keyword, and we declare its operations by giving a list of GADT-style signatures. In this case, the fail operation is

22

C. van der Rest and C. B. Poulsen

declared to have type {[Fail ] a }. We enclose the type of fail in braces ({−}) to indicate that the name fail refers to a suspended computation (Sect. 2.3). Suspended computations are annotated with an effect row, enclosed in square brackets ([−]), denoting the side effects of running the computation. Invoking the fail operation has Fail as a side effect. We can use the Fail effect to implement a safe division function that invokes fail if the divisor is zero. fun safeDiv : Int → Int → [Fail ] Int where | x 0 → fail! | x y → ... The postfix exclamation mark to fail is necessary to force the suspended computation. Here, we want to refer to the “action” of failing, rather than the computation itself, following Frank’s [11] separation between “being and doing”. We elaborate on this distinction in Sect. 2.3. A function’s type signature must explicitly indicate its side-effects. In this case, we annotate the return type of safeDiv with the Fail effect to indicate that its implementation uses operations of this effect. Removing the annotation would make the above invocation of fail ill-typed. For functions that have no side-effects, we may omit its row annotation: a → b is synonymous to a → [ ] b Handlers discharge an effect from annotations by assigning a semantics to its operations. For the Fail effect, we can do this by encoding exceptions in the Maybe type. data Maybe a = Just a | Nothing handler hFail : {[Fail |e ] a } → {[e ] (Maybe a)} where | fail k → {Nothing } | return x → {Just x } The handler hFail takes a value annotated with the Fail effect, and produces a Maybe value annotated with the remaining effects e. All free effect row variables in a signature, like e, are implicitly generalized over. When defining a handler we must provide a clause for each operation of the handled effect. Additionally, we must write a return clause that lifts pure values into the domain that encodes the effect’s semantics. Operation clauses have a continuation parameter, k , with type b → [e ] (Maybe a), which captures the remainder of the program starting from the current operation. Handlers may use the continuation parameter to decide how execution should resume after the current operation is handled. For example, when handing the fail operation we terminate execution of the program by ignoring this continuation. We use the continuation parameter in a different way when defining a handler for a State effect, where s : Set is a parameter of the module in which we define the effect.

Towards a Language for Defining Resuable Language Components

23

effect State where | get : [State ] s | put : s → [State ] () handler hState : {[State|e ] a } → s → {[e ] (a ∗ s)} where | get st k → k st st | (put st  ) st k → k () st  | return x st → {(x , st)} For both the get and put operations, we use the continuation parameter k to implement the corresponding branch in hState. The continuation expects a value whose type corresponds to the return type of the current operation, and produces a computation with the same type as the return type of the handler. For the put operation, for example, this means that k is of type () → s → {[e ] (a ∗ s)}. The implementation of hState for get and put then simply invokes k , using the current state as both the value and input state (get), or giving a unit value and using the given state st  as the input state (put). Effectively, this means that after handling get or put, execution of the program resumes, respectively with the same state or an updated state st  . Handlers in CS are so-called deep handlers, meaning that they are automatically distributed over continuations. For the example above, this means that that the State effect is already handled in the computation returned by k . The alternative is shallow handlers, in which case k would return a computation of type {[State|e ] a }. When using shallow handlers, the programmer is responsible for recursively applying the handler to the result of continuations. While shallow handlers are more expressive, unlike deep handlers they are not guaranteed to correspond to a fold over the effect tree, meaning that they are potentially harder to reason about. 2.3

Order of Evaluation, Suspension, and Enactment

Inspired by Frank [11], CS allows effectful computations to be used as if they were pure values, without having to sequence them. Sub-expressions in CS are evaluated from left to right, and the side-effects of computational sub-expressions are evaluated eagerly in that order. For example, consider the following program: fun f : Int → [Fail ] Int where | n → fail! + n Here, we use the expression fail! (whose type is instantiated to [Fail ] Int) as the first argument to +, where a value of type Int is expected. This is fine, because side-effects that occur during evaluation of sub-terms are discharged to the surrounding context. That is, the side-effects of evaluating computational sub-terms in the definition of f become side-effects of f itself. In practice, this means that function application in CS is not unlike programming in an applicative style in Haskell. For instance, when using the previouslydefined handler hFail , which maps the Fail effect to a Maybe, we can informally

24

C. van der Rest and C. B. Poulsen

understand the semantics of the CS program above as equivalent to the following Haskell program: f :: Int → Maybe Int f n = (+) Nothing pure n Equivalently, we could write the following monadic program in Haskell, which makes the evaluation order explicit. f :: Int → Maybe Int f n = do x ← Nothing y ← pure n return (x + y) CS’s eager treatment of side-effects means that effectful computations are not first-class values, in the sense that we cannot refer to an effectful computation without triggering its side effects. To treat computations as first-class, we must explicitly suspend their effects using braces: fun f  : Int → {[Fail ] Int } where | n → {f n } The function f  is no longer a function on Int that may fail, but instead a function from Int to a computation that returns and Int, but that could also fail. We indicate a suspended computation in types using braces ({/}), and construct a suspension at the term level using the same notation. To enact the side-effects of a suspended computation, we postfix it with an exclamation mark (!). For example, the expression (f  0)! has type [Fail ] Int, whereas the expression (f  0) has type {[Fail ] Int }. We see the same distinction with operations declared using the effect keyword. When we write fail, we refer to the operation in a descriptive sense, and we can treat it like any other value without having to worry about its side-effects. When writing fail! , on the other hand, we are really performing the action of abruptly terminating: fail is and fail! does. 2.4

Modules and Imports

CS programs are organized using modules. Modules are delimited using the module and end keywords, and their definitions can be brought into scope elsewhere using the import keyword. All declarations—i.e., data types, functions, effects, and handlers—must occur inside a module. module A where fun f : Int → Int where | n → n + n end module B where

Towards a Language for Defining Resuable Language Components

25

import A fun g : Int → Int where | n → f n end In addition to being an organizational tool, modules play a key role in defining and composing modular data types and functions. 2.5

Composable Data Types and Functions

In addition to plain algebraic data types and pattern matching functions, declared using the data and fun keywords, CS also supports case-by-case definitions of extensible data types and functions. In effect, CS provides a convenient surface syntax for working with DTC-style [33] definitions, which relies on an embedding of the initial algebra semantics [13] of data types to give a semantics to extensible and composable algebraic data types and functions, meaning that extensible functions have to correspond to a fold [20]. In CS, one can program with extensible data types and functions in the same familiar way as with their plain, non-extensible counterparts. The module system plays an essential role in the definition of composable data types and functions. That is, modules can inhabit a signature that declares the extensible types and functions for which that module can give a partial definition. In a signature declaration, we use the keyword sort to declare an extensible data type, and the alg keyword to declare an extensible function, or algebra. By requiring extensible functions to be defined as algebras over the functor semantics of extensible data types, we enforce by construction that they correspond to a fold. As an example, consider the following signature that declares an extensible data type Expr , which can be evaluated to an integer using eval . signature Eval where sort Expr : Set alg eval : Expr → Int end To give cases for Expr and eval , we define modules that inhabit the Eval signature. module Lit : Eval where cons Lit : Int → Expr case eval (Lit x ) → x end module Add : Eval where cons Add : Expr → Expr → Expr case eval (Add x y) → x + y end

26

C. van der Rest and C. B. Poulsen

The cons keyword declares a new constructor for an extensible data type, where we declare any arguments by giving a GADT-style type signature. We declare clauses for functions that match on an extensible type using the case keyword. For every newly declared constructor of an extensible data type, we have an obligation to supply exactly one corresponding clause for every extensible function that matches on that type. CS has a coverage checker that checks whether modules indeed contain all necessary definitions, in order to rule out partiality resulting from missing patterns. For example, omitting the eval case from either the module Lit or Add above will result in a static error. Coverage is checked locally in modules, and preserved when composing signature instances. In the definition of eval in the module Add , we see the implications of defining function clauses as algebras. We do not have direct control over recursive calls to eval . Instead, in case declarations, any recursive arguments to the matched constructor are replaced with the result of recursively invoking eval on them. In this case, this implies that x and y do not refer to expressions. Rather, if we invoke eval on the expression Add e1 e2 , in the corresponding case declaration, x and y are bound to eval e1 and eval e2 respectively. We could encode the same example in Haskell as follows, but to use eval on concrete expressions additionally requires explicit definitions of a type level fixpoint and fold operation. In CS, this encoding layer is hidden by the language. data Add e = Add e e eval :: Add Int → Int eval (Add x y) = x + y To compose signature instances we merely have to import them from the same location. module Program where import Lit, Add - - Evaluates to 3 fun test : Int = eval (Add (Lit 1) (Lit 2)) end By importing both the Lit and Add modules, the names Expr and eval will refer to the composition of the constructors/clauses defined in the imported signature instances. Here, this means that we can construct and evaluate expressions that consist of both literals and addition. Furthermore, to add a new constructor into the mix, we can simply define a new module that instantiates the Eval signature, and add it to the import list. To define an alternative interpretation for Expr , we declare a new signature. In order to reference the sort declaration for Expr , we must import the Eval signature.

Towards a Language for Defining Resuable Language Components

27

signature Pretty where import Eval - - brings ’Expr’ into scope alg pretty : Expr → String end We declare cases for pretty by instantiating the newly defined signature, adding import declarations to bring relevant cons declaration into scope. module PrettyAdd : Pretty where import Add - - brings ’Add’ into scope case pretty (Add s1 s2 ) = s1 ++ “ + ” ++ s2 end It is possible for two modules to be conflicting, in the sense that they both define an algebra case for the same constructor. This would happen, for example, if we were to define another module PrettyAdd2 that also implements pretty for the constructor Add . Importing two conflicting modules should result in a type error, since the semantics of their composition is unclear.

3

Defining Reusable Language Components in CS

In this section, we demonstrate how to use the features of CS introduced in the previous section to define reusable language components. We work towards defining a reusable component for function abstraction, which can be composed with other constructs, and for which we can define alternative implementations. As an example, we will show that we can use the same component defining functions with both a call-by-value and call-by-name strategy. 3.1

A Signature for Reusable Components

The first step is to define an appropriate module signature. We follow the same setup as for the Eval signature in Sect. 2.5. That is, we declare an extensible sort Expr , together with an algebra eval that consumes values of type Expr . The result of evaluation is a Value, with potential side effects e. The side effects are still abstract in the signature definition. The effect variable e is universally quantified, but instantiations of the signature Eval can impose additional constraints depending on which effects they need to implement eval . As a result, when invoking eval , e can be instantiated with any effect row that satisfies all the constraints imposed by the instances of Eval that are in scope. signature Eval where sort Expr : Set alg eval : Expr → {[e ] Value } end We will consider the precise definition of Value later in Sect. 3.3. For now, it is enough to know that it has a constructor Num : Int → Value that constructs a value from an integer literal.

28

3.2

C. van der Rest and C. B. Poulsen

A Language Component for Arithmetic Expressions

Let us start by defining instances of the Eval signature for the expression language from the introduction. First, we define a module for integer literals. module Lit : Eval where cons Lit : Int → Expr case eval (Lit n) = {Num n } end The corresponding clause for eval simply returns the value n stored inside the constructor. Because the interpreter expects that we return a suspended computation, we must wrap n in a suspension, even though it is a pure value. Enacting this suspension, however, does not trigger any side effects, and as such importing Lit imposes no constraints on the effect row e. Next, we define a module Div that implements integer division. module Div : Eval where cons Div : Expr → Expr → Expr case eval (Div m1 m2 ) = {safeDiv m1 ! m2 ! } Looking at the implementation of eval in the module Div we notice two things. First, the recursive arguments to Div have been replaced by the result of calling eval on them, meaning that m1 and m2 are now computations with type {[e ] Int }, and hence we must use enactment before we can pass the result to safeDiv . Enacting these computations may trigger side effects, so the order in which sub-expressions are evaluated determines in which order these side effects occur in the case that expressions contain more than one enactment. Sub-expressions in CS are evaluated from left to right. Second, the implementation uses the function safeDiv , defined in Sect. 2.2, which guards against errors resulting from division by zero. The function safeDiv is annotated with the Fail effect, which supplies the fail operation. By invoking safeDiv in the defintion of eval , which from the definition of Eval has type Expr → {[e ] Int }, we are imposing a constraint on the effect row e that it contains at least the Fail effect. In other words, whenever we import the module Div we have to make sure that we instantiate e with a row that has Fail in it. Consequently, before we can extract a value from any instantiation of Eval that includes Div , we must apply a handler for the Fail effect. Since the interpreter now returns a Value instead of an Int, we must modify safeDiv accordingly. In practice this means that we must check if its arguments are constructed using the Num constructor before further processing the input. Since safeDiv already has Fail as a side effect, we can invoke the fail operation in case an argument was constructed using a different constructor than Num. 3.3

Implementing Functions as a Reusable Effect

CS’s effect system can describe much more sophisticated effects than Fail . The effect system permits fine-grained control over the semantics of operations that

Towards a Language for Defining Resuable Language Components

29

affect a program’s control flow, even in the presence of other effects. To illustrate its expressiveness, we will now consider how to define function abstraction as a reusable effect, and implement two different handlers for this effect corresponding to a call-by-value and call-by-name semantics. Implementing function abstraction as an effect is especially challenging since execution of the function body is deferred until the function is applied. From a handler’s perspective, this means that the function body and its side effect have to be postponed until a point beyond its own control, a pattern that is very difficult to capture using traditional algebraic effects. We will see shortly how CS addresses this challenge. A key part of the solution is the ability to define higher-order operations: operations with arguments that are themselves effectful computations, leaving it up to the operation’s handler to enact the side effects of higher-order arguments. The Fun effect, which implements function abstraction, has several higher-order operations. effect Fun where | lam : String → {[Fun ] Value } → {[Fun ] Value } | app : Value → Value → {[Fun ] Value } | var : String → {[Fun ] Value } | thunk : {[Fun ] Value } → {[Fun ] Value } The Fun effect defines four operations, three of which correspond to the usual constructs of the λ-calculus. The thunk operation has no counterpart in the λ-calculus, and postpones evaluation of a computation. It is necessary for evaluation to support both a call-by-value and call-by-name evaluation strategy When looking at the lam and thunk operations, we find that they both have parameters annotated with the Fun effect. This annotation indicates that they are higher-order parameters. By allowing higher-order parameters to operations, effects in CS do not correspond directly to algebraic effects. Instead, to give as semantics to effects in CS, we must use a flavor of effects that permits higherorder syntax, such as Latent Effects [7]. As a result, any effects of the computations stored in a closure or thunk are postponed, leaving it up to the handler to decide when these take place. Using the Fun effect To build a language with function abstractions that uses the Fun effect, we give an instance of the Eval signature that defines the constructors Abs, App, and Var for Expr . We extend eval for these constructors by mapping onto the corresponding operation. module Lambda : Eval where cons Abs : String → Expr → Expr | App : Expr → Expr → Expr | Var : String → Expr case eval (Abs x m) = lam x m | eval (App m1 m2 ) = app m1 ! (thunk m2 )! | eval (Var x ) = var x end

30

C. van der Rest and C. B. Poulsen

Crucially, in the case for Abs we pass the effect-annotated body m, with type {[e ] Value }, to the lam operation directly without extracting a pure value first. This prevents any effects in the body of a lambda from being enacted at the definition site, and instead leaves the decision of when these effects should take place to the used handler for the Fun effect. Similarly, in the case for App, we pass the function argument m2 to the thunk operation directly, postponing any side effects until we force the constructed thunk. The precise moment at which we will force the thunk constructed for function arguments will depend on whether we employ a call-by-value or call-by-name strategy. We must, however, enact the side effects of evaluating the function itself (i.e., m1 ), because the app operation expects its arguments to be a pure value. We implement call-by-value and call-by-name handlers for Fun in a new module, which also defines the type of values, Value, for our language. To keep the exposition simple, Value is not an extensible sort, but it is possible to do so in CS. Values in this language are either numbers (Num), function closures (Clo), or thunked computations (Thunk ). We define the type of values together in the same module as the handler(s) for the Fun effect. This module is parameterized over an effect row e, that denotes the remaining effects that are left after handling the Fun effect. In this case, e is a module parameter to express that the remaining effects in the handlers that we will define coincide with the effect annotations of the computations stored in the Clo and Thunk constructors, allowing us to run these computations in the handler. module HLambda (e : Effects) where import Fun type Env = List (String ∗ Value) data Value = Num Int | Clo String (Env → {[Fail |e ] Value }) Env | Thunk ({[Fail |e ] Value }) - - ... (handlers for the Fun effect) ... end Call-by-value We are now ready to define a handler for the Fun effect that implements a call-by-value evaluation strategy. Figure 1 shows its implementation. The return case is unremarkable: we simply ignore the environment nv and return the value v . The cases for lam and thunk are similar, as in both cases we do not enact the side effects associated with the stored computation f , but instead wrap this computation in a Closure or Thunk which is passed to the continuation k . For variables, we resolve the identifier x in the environment and pass the result to the continuation. A call-by-value semantics arises from the implementation of the app case. The highlights (e.g., t! ) indicate where the thunk we constructed for the function argument in eval is forced. In this case, we force this argument thunk immediately when encountering a function application, meaning that any side effects of the argument take place before we evaluate the function body.

Towards a Language for Defining Resuable Language Components

31

Fig. 1. A Handler for the Fun effect, implementing a call-by-value semantics for function arguments. The gray highlights indicate where thunks constructed for function arguments are forced.

Fig. 2. A Handler for the Fun effect, implementing a call-by-name semantics for function arguments. The gray highlights indicate where thunks constructed for function arguments are forced.

Call-by-name. The handler in Fig. 2 shows an implementation of a call-by-name semantics for the Fun effect. The only case that differ from the call-by-value handler in Fig. 1 are the app and var cases. In the case for app, we now put the argument thunk in the environment immediately, without forcing it first. Instead, in the case for var, we check if the variable we look up in the environment is a Thunk . If so, we force it and pass the resulting value to the continuation. In effect, this means that for a variable that binds an effectful computation, the associated side effects take place every time we use that variable, but not until we reference it for the first time. 3.4

Example Usage

To illustrate how to use the reusable components defined in this section, and the difference between the semantics implemented by hCBV (Fig. 1) and hCBN

32

C. van der Rest and C. B. Poulsen

Fig. 3. Examples of different outcomes when using a call-by-value or call-by-name evaluation strategy.

(Fig. 2), we combine the Lambda module with the modules for Div and Lit. Figure 3 shows the example. When importing the modules HLambdaCBV and HLambdaCBN , we pass an explicit effect row that corresponds to the effects that remain after handling the Fun effect. Because we handle Fun after handling the Fail effect introduced by Div , we pass the empty row. To evaluate expressions, we have to invoke hFail twice: first to handle the instance of the Fail effect introduced by eval for the Div constructor, and later to handle the Fail instance introduced by applying hCBV /hCBN . Consequently, the result of evaluating is a nested Maybe, where the inner instance indicates errors resulting from division by zero, and the outer instance errors thrown by the handler. Evaluating result1 and result2 shows the difference between using the call-by-value and call-by-name semantics for functions.

4

Related Work

Effect Semantics. Monads, originally introduced by Moggi [21] have long been the dominant approach to modelling programs with side effects. They are, however, famously hard to compose, leading to the development of monad transformers [18] as a technique for building monads from individual definitions of

Towards a Language for Defining Resuable Language Components

33

effects. Algebraic effects [25] provide a more structured approach towards this goal, where an effect is specified in terms of the operations that we can use to interact with it. The behaviour of these operations is governed by a set of equations that specify its well-behavedness. Later, Plotkin and Pretnar [26] extended the approach with handlers, which define interpretations of effectful operations by defining a homomorphism from a free model that trivially inhabits the equational theory (i.e., syntax) to a programmer-defined domain, making the approach attractive for implementing effects as well. Perhaps the most well-known implementation of algebraic effects and handlers is the free monad [15], and this implementation is often taken as the semantic foundation of languages with support for effect handlers. Schrijvers et al. [30] showed that algebraic effects implemented using the free monad correspond to a sub-class of monad-transformers. The algebraic effects and handlers approach provides a solid formal framework for understanding effectful programs in which we intend to ground CS’ semantics of effects and handlers. A crucial difference between CS’ effects and handlers, and the original formulation by Plotkin and Pretnar [26], is the support for higher-order operations. Although it is possible to implement such operations in algebraic effects by defining them as handlers, this breaches the separation between the syntax and semantics of effects that underpins CS’ design. Scoped Effects [36] were proposed as an alternative flavor of algebraic effects that supports higher-order syntax, recovering a separation between the syntax semantics of effects for higher-order operations. In subsequent work, Pir´ og et al. [24] adapted the categorical formulation of algebraic effects to give Scoped Effects a similar formal underpinning. Unfortunately, Scoped Effects is not suitable out-of-the-box as a model for effects and handlers in CS, because it cannot readily capture operations that arbitrarily postpone the execution of their higher-order arguments, such as lam. Latent effects were developed by Van den Berg et al. [7] as a refinement of scoped effects that solves this issue. Key to their approach is a latent effect functor, which explicitly tracks semantic residue of previously-installed handlers, allowing for a more fine-grained specification of the types of the computational arguments of operations. With Latent Effects, it is possible to capture function abstraction as a higher-order operation. It remains future work to formulate a precise model of effectful computation for CS, and to establish if and how CS’ effect handlers correspond to Latent Effects. Implementations of Algebraic Effects and Handlers. There are many languages with support for algebraic effects and handlers. Perhaps the most mature is Koka [16], which features a Hindley/Milner-style row-polymorphic type system. While we borrow from Frank [11] a CBPV-inspired [17] distinction between computations and values, Koka is purely call-by-value, and only functions can be effectful. Frank [11], on the other hand, does maintain this distinction between values and computations. Its type system relies on an ambient ability and implicit row polymorphism to approximate effects. Handlers are not first-class constructs in Frank. Instead, functions may adjust the ambient ability of their arguments by specifying the behaviour of operations. This provides some additional flexibility

34

C. van der Rest and C. B. Poulsen

over built-in handers, for example by permitting multihandlers that handle multiple effects at once. Both Koka and Frank lack native support for higher order effects, thus higher-order operations must be encoded in terms of handlers. This means that it is not possible to define higher order operations while maintaining the aforementioned distinction between the syntax and semantics of effects. Eff [6] is a functional language with support for algebraic effects and handlers, with the possibility to dynamically generate new operations and effects. In later work, Bauer and Pretnar [5] developed a type-and-effect system for Eff, together with an inference algorithm [27]. The language Links [19] employs row-typed algebraic effects in the context of database programming. Their system is based on System F extended with effect rows and row polymorphism, and limits effectful computations to functions similar to Koka. Importantly, their system tracks effects using R´emy-style rows [28], maintaining so-called presence types that can additionally express an effect’s absence from a computation. Brachth¨auser et al. [9] presented Effekt as a more practical implementation of effects and handlers, using capability based type system where effect types express a set of capabilities that a computation requires from its context. Semantics of Composable Data Types and Functions. We give a semantics to extensible data types and functions in CS using the initial algebra semantics [13] of an underlying signature functor. Data Types a ` la Carte (DTC) [33] solves the expression problem in Haskell by embedding this semantics into the host language. In later work, Bahr [2] and Bahr and Hvitved [3,4] extended the approach to improve its expressiveness and flexibility. DTC, like any approach that relies on initial algebra semantics, limits the modular definition of functions to functions that correspond to a fold over the input data. While this may seem restrictive, in practice more complicated traversals can often be encoded as a fold, such as paramorphisms [20] or some classes of attribute grammars [14]. While CS currently only has syntax for plain algebras and folds, we plan to extend the syntax for working with extensible data types and functions to accomodate a wider range of traversals in the future. Carette et al. [10] showed how to define interpreters by encoding object language expressions as a term that represents their traversal. These traversals correspond to a fold, but abstract over the algebra that defines the computation, meaning that alternative semantics can be assigned to terms by picking a suitable algebra. Semantics are defined as type class instances in the host language, thus one can build object language terms in a modular way by defining multiple different type classes that correspond to different syntactical constructs. Row Types. While a concrete design for CS’ type system is still emerging, we anticipate that it will make heavy use of row types, both for tracking effects and typing extensible types and functions. While to the best of our knowledge no type system exists with this combination of features, all the ingredients are there in the literature. Originally, row types were incepted as a means to model inheritance in object-oriented languages [28,35], and later extensible records [8,12].

Towards a Language for Defining Resuable Language Components

35

More recently, they also gained popularity in the form of row-based effect systems with the development of languages such as Koka [16] and Links [19]. Their use for typing extensible algebraic data types and pattern matching functions is less well-studied. For the most part, row types in this context exist implicitly as part of encoding techniques such as DTC [33], where we can view the use of signature functors and functor co-products as an embedding of row-typed extensible variants in the host language’s type system. Various refinements of DTC [2,22,29] make this connection more explicit by using type-level lists to track the composition of extensible data. A notable exception is the Rose [23] language, which has a row-based type system with built-in support for extensible variants and pattern matching function.

5

Future Work

CS is an ongoing research project. Here, we briefly summarize the current state, and some of the challenges that still remain. While we can implement the examples from this paper in the current prototype implementation of CS, the language still lacks a complete specification. As such, the immediate next steps are to develop specifications of the type system and operational semantics. This requires us to address several open research questions, such as giving a semantics to the flavor of higher-order effects used by CS, and applying row types to type CS’ extensible data types and functions. While the Rose language supports extensible variants, this support is limited to non-recursive types. For CS, we would need to adapt their type sytem to support recursive extensible data types as well. Designing a core calculus for CS could be potential way to explore these questions, making a formalization of the language in a proof assistant more attainable, by formalizing the core language. Further down the line, we also intend to explore a denotational model for effect handlers in CS, giving the language a more solid formal foundation, similar to existing programming languages based on algebraic effects and handlers. In the future, we also hope to enforce stronger properties about specifications defined in CS through the language’s type system. The prime example are intrinsically-typed definitional interpreters [1], which specify a language’s operational semantics such that it is type sound by construction.

6

Conclusion

Reusable programming language components have the potential to significantly reduce the amount of time, effort, and expertise needed for developing programming languages. In this paper, we presented CS, a functional meta-language for defining reusable programming language components. CS enables the defintion of reusable language components using algebraic data types and pattern matching functions, by supporting extensible data types and functions, which are defined on a case-by-case basis. Additionally, CS features built-in support for effects and handlers for defining the side effects of a language. The flavor of

36

C. van der Rest and C. B. Poulsen

effects and handlers implemented by CS supports higher-order operations, and can be used to define features that affect a program’s control flow, such as function abstraction, as a reusable effect. We illustrated how these features can be used for developing reusable programming language components by defining a component for function abstraction, which can be composed with other language components and evaluated using both a call-by-value and call-by-name strategy. Acknowledgements. We would like to thank Peter Mosses, for his valuable advice during several discussions about this work. Furthermore, we thank the anonymous reviewers for their reviews and helpful feedback. This research was partially funded by the NWO VENI Composable and Safe-by-Construction Programming Language Definitions project (VI.Veni.192.259).

References 1. Augustsson, L., Carlsson, M.: An exercise in dependent types: a well-typed interpreter. In: Workshop on Dependent Types in Programming, Gothenburg (1999) 2. Bahr, P.: Composing and decomposing data types: a closed type families implementation of data types a ` la carte. In: Magalh˜ aes, J.P., Rompf, T. (eds.) Proceedings of the 10th ACM SIGPLAN workshop on Generic programming, WGP 2014, Gothenburg, Sweden, 31 August 2014, pp. 71–82. ACM (2014). https://doi.org/10.1145/ 2633628.2633635 3. Bahr, P., Hvitved, T.: Compositional data types. In: J¨ arvi, J., Mu, S. (eds.) Proceedings of the 7th ACM SIGPLAN workshop on Generic programming, WGP@ICFP 2011, Tokyo, Japan, 19–21 September 2011, pp. 83–94. ACM (2011). https://doi.org/10.1145/2036918.2036930 4. Bahr, P., Hvitved, T.: Parametric compositional data types. In: Chapman, J., Levy, P.B. (eds.) Proceedings 4th Workshop on Mathematically Structured Functional Programming, MSFP@ETAPS 2012, Tallinn, Estonia, 25 March 2012. EPTCS, vol. 76, pp. 3–24 (2012), https://doi.org/10.4204/EPTCS.76.3 5. Bauer, A., Pretnar, M.: An effect system for algebraic effects and handlers. Log. Methods Comput. Sci. 10(4) (2014). https://doi.org/10.2168/LMCS-10(4:9)2014 6. Bauer, A., Pretnar, M.: Programming with algebraic effects and handlers. J. Log. Algebraic Methods Program. 84(1), 108–123 (2015). https://doi.org/10.1016/j. jlamp.2014.02.001 7. van den Berg, B., Schrijvers, T., Poulsen, C.B., Wu, N.: Latent effects for reusable language components. In: Oh, H. (ed.) APLAS 2021. LNCS, vol. 13008, pp. 182– 201. Springer, Cham (2021). https://doi.org/10.1007/978-3-030-89051-3 11 8. Blume, M., Acar, U.A., Chae, W.: Extensible programming with first-class cases. In: Reppy, J.H., Lawall, J.L. (eds.) Proceedings of the 11th ACM SIGPLAN International Conference on Functional Programming, ICFP 2006, Portland, Oregon, USA, 16–21 September 2006, pp. 239–250. ACM (2006). https://doi.org/10.1145/ 1159803.1159836 9. Brachth¨ auser, J.I., Schuster, P., Ostermann, K.: Effects as capabilities: effect handlers and lightweight effect polymorphism. In: Proceedings of the ACM Programming Languages, vol. 4(OOPSLA), pp. 126:1–126:30 (2020). https://doi.org/10. 1145/3428194

Towards a Language for Defining Resuable Language Components

37

10. Carette, J., Kiselyov, O., Shan, C.: Finally tagless, partially evaluated: tagless staged interpreters for simpler typed languages. J. Funct. Program. 19(5), 509– 543 (2009). https://doi.org/10.1017/S0956796809007205 11. Convent, L., Lindley, S., McBride, C., McLaughlin, C.: Doo bee doo bee doo. J. Funct. Program. 30, e9 (2020). https://doi.org/10.1017/S0956796820000039 12. Gaster, B.R., Jones, M.P.: A polymorphic type system for extensible records and variants. Technical report Citeseer (1996) 13. Johann, P., Ghani, N.: Initial algebra semantics is enough! In: Della Rocca, S.R. (ed.) TLCA 2007. LNCS, vol. 4583, pp. 207–222. Springer, Heidelberg (2007). https://doi.org/10.1007/978-3-540-73228-0 16 14. Johnsson, T.: Attribute grammars as a functional programming paradigm. In: Kahn, G. (ed.) FPCA 1987. LNCS, vol. 274, pp. 154–173. Springer, Heidelberg (1987). https://doi.org/10.1007/3-540-18317-5 10 15. Kammar, O., Lindley, S., Oury, N.: Handlers in action. In: Morrisett, G., Uustalu, T. (eds.) ACM SIGPLAN International Conference on Functional Programming, ICFP’13, Boston, MA, USA - 25–27 September 2013, pp. 145–158. ACM (2013). https://doi.org/10.1145/2500365.2500590 16. Leijen, D.: Type directed compilation of row-typed algebraic effects. In: Castagna, G., Gordon, A.D. (eds.) Proceedings of the 44th ACM SIGPLAN Symposium on Principles of Programming Languages, POPL 2017, Paris, France, 18–20 January 2017, pp. 486–499. ACM (2017). https://doi.org/10.1145/3009837.3009872 17. Levy, P.B.: Call-By-Push-Value: A Functional/Imperative Synthesis, Semantics Structures in Computation, vol. 2. Springer, Cham (2004) 18. Liang, S., Hudak, P., Jones, M.P.: Monad transformers and modular interpreters. In: Cytron, R.K., Lee, P. (eds.) Conference Record of POPL’95: 22nd ACM SIGPLAN-SIGACT Symposium on Principles of Programming Languages, San Francisco, California, USA, 23–25 January 1995, pp. 333–343. ACM Press (1995). https://doi.org/10.1145/199448.199528 19. Lindley, S., Cheney, J.: Row-based effect types for database integration. In: Pierce, B.C. (ed.) Proceedings of TLDI 2012: The Seventh ACM SIGPLAN Workshop on Types in Languages Design and Implementation, Philadelphia, PA, USA, 28 January 2012, pp. 91–102. ACM (2012). https://doi.org/10.1145/2103786.2103798 20. Meijer, E., Fokkinga, M., Paterson, R.: Functional programming with bananas, lenses, envelopes and barbed wire. In: Hughes, J. (ed.) FPCA 1991. LNCS, vol. 523, pp. 124–144. Springer, Heidelberg (1991). https://doi.org/10.1007/3540543961 7 21. Moggi, E.: Notions of computation and monads. Inf. Comput. 93(1), 55–92 (1991). https://doi.org/10.1016/0890-5401(91)90052-4 22. Morris, J.G.: Variations on variants. In: Lippmeier, B. (ed.) Proceedings of the 8th ACM SIGPLAN Symposium on Haskell, Haskell 2015, Vancouver, BC, Canada, 3–4 September 2015, pp. 71–81. ACM (2015). https://doi.org/10.1145/2804302. 2804320 23. Morris, J.G., McKinna, J.: Abstracting extensible data types: or, rows by any other name. In: Proceedings of the ACM Programming Languages, vol. 3(POPL), pp. 12:1–12:28 (2019). https://doi.org/10.1145/3290325 24. Pir´ og, M., Schrijvers, T., Wu, N., Jaskelioff, M.: Syntax and semantics for operations with scopes. In: Dawar, A., Gr¨ adel, E. (eds.) Proceedings of the 33rd Annual ACM/IEEE Symposium on Logic in Computer Science, LICS 2018, Oxford, UK, 09–12 July 2018, pp. 809–818. ACM (2018). https://doi.org/10.1145/3209108. 3209166 25. Plotkin, G.D., Power, J.: Algebraic operations and generic effects. Appl. Categorical Struct. 11(1), 69–94 (2003). https://doi.org/10.1023/A:1023064908962

38

C. van der Rest and C. B. Poulsen

26. Plotkin, G., Pretnar, M.: Handlers of algebraic effects. In: Castagna, G. (ed.) ESOP 2009. LNCS, vol. 5502, pp. 80–94. Springer, Heidelberg (2009). https://doi.org/10. 1007/978-3-642-00590-9 7 27. Pretnar, M.: Inferring algebraic effects. Log. Methods Comput. Sci. 10(3) (2014). https://doi.org/10.2168/LMCS-10(3:21)2014 28. R´emy, D.: Typechecking records and variants in a natural extension of ML. In: Conference Record of the 16th Annual ACM Symposium on Principles of Programming Languages, Austin, Texas, USA, 11–13 January 1989, pp. 77–88. ACM Press (1989). https://doi.org/10.1145/75277.75284 29. d. S. Oliveira, B.C., Mu, S., You, S.: Modular reifiable matching: a list-of-functors approach to two-level types. In: Lippmeier, B. (ed.) Proceedings of the 8th ACM SIGPLAN Symposium on Haskell, Haskell 2015, Vancouver, BC, Canada, 3–4 September 2015, pp. 82–93. ACM (2015). https://doi.org/10.1145/2804302. 2804315 30. Schrijvers, T., Pir´ og, M., Wu, N., Jaskelioff, M.: Monad transformers and modular algebraic effects: what binds them together. In: Eisenberg, R.A. (ed.) Proceedings of the 12th ACM SIGPLAN International Symposium on Haskell, Haskell@ICFP 2019, Berlin, Germany, 18–23 August 2019, pp. 98–113. ACM (2019). https://doi. org/10.1145/3331545.3342595 31. Strachey, C.: Towards a formal semantics (1966) 32. Strachey, C.S.: Fundamental concepts in programming languages. High. Order Symb. Comput. 13(1/2), 11–49 (2000). https://doi.org/10.1023/A:1010000313106 33. Swierstra, W.: Data types ` a la carte. J. Funct. Program. 18(4), 423–436 (2008). https://doi.org/10.1017/S0956796808006758 34. Wadler, P.: The expression problem. http://homepages.inf.ed.ac.uk/wadler/ papers/expression/expression.txt (1998). Accessed 04 April 2022 35. Wand, M.: Type inference for record concatenation and multiple inheritance. In: Proceedings of the 4th Annual Symposium on Logic in Computer Science (LICS ’89), Pacific Grove, California, USA, 5–8 June 1989, pp. 92–97. IEEE Computer Society (1989). https://doi.org/10.1109/LICS.1989.39162 36. Wu, N., Schrijvers, T., Hinze, R.: Effect handlers in scope. In: Swierstra, W. (ed.) Proceedings of the 2014 ACM SIGPLAN symposium on Haskell, Gothenburg, Sweden, 4–5 September 2014, pp. 1–12. ACM (2014). https://doi.org/10.1145/2633357. 2633358

Deep Embedding with Class Mart Lubbers(B) Institute for Computing and Information Sciences, Radboud University Nijmegen, Nijmegen, The Netherlands [email protected]

Abstract. The two flavours of DSL embedding are shallow and deep embedding. In functional languages, shallow embedding models the language constructs as functions in which the semantics are embedded. Adding semantics is therefore cumbersome while adding constructs is a breeze. Upgrading the functions to type classes lifts this limitation to a certain extent. Deeply embedded languages represent their language constructs as data and the semantics are functions on it. As a result, the language constructs are embedded in the semantics, hence adding new language constructs is laborious where adding semantics is trouble free. This paper shows that by abstracting the semantics functions in deep embedding to type classes, it is possible to easily add language constructs as well. So-called classy deep embedding results in DSLs that are extensible both in language constructs and in semantics while maintaining a concrete abstract syntax tree. Additionally, little type-level trickery or complicated boilerplate code is required to achieve this. Keywords: Functional programming domain-specific languages

1

· Haskell · Embedded

Introduction

The two flavours of DSL embedding are deep and shallow embedding [4]. In functional programming languages, shallow embedding models language constructs as functions in the host language. As a result, adding new language constructs— extra functions—is easy. However, the semantics of the language is embedded in these functions, making it troublesome to add semantics since it requires updating all existing language constructs. On the other hand, deep embedding models language constructs as data in the host language. The semantics of the language are represented by functions over the data. Consequently, adding new semantics, i.e. novel functions, is straightforward. It can be stated that the language constructs are embedded in the functions that form a semantics. If one wants to add a language construct, all semantics functions must be revisited and revised to avoid ending up with partial functions. This juxtaposition has been known for many years [19] and discussed by many others [11] but most famously dubbed the expression problem by Wadler [24]: c Springer Nature Switzerland AG 2022  W. Swierstra and N. Wu (Eds.): TFP 2022, LNCS 13401, pp. 39–58, 2022. https://doi.org/10.1007/978-3-031-21314-4_3

40

M. Lubbers

The expression problem is a new name for an old problem. The goal is to define a data type by cases, where one can add new cases to the data type and new functions over the data type, without recompiling existing code, and while retaining static type safety (e.g., no casts). In shallow embedding, abstracting the functions to type classes disentangles the language constructs from the semantics, allowing extension both ways. This technique is dubbed tagless-final embedding [5], nonetheless it is no silver bullet. Some semantics that require an intensional analysis of the syntax tree, such as transformation and optimisations, are difficult to implement in shallow embedding due to the lack of an explicit data structure representing the abstract syntax tree. The semantics of the DSL have to be combined and must hold some kind of state or context, so that structural information is not lost [10]. 1.1

Research Contribution

This paper shows how to apply the technique observed in tagless-final embedding to deep embedding. The presented basic technique, christened classy deep embedding, does not require advanced type system extensions to be used. However, it is suitable for type system extensions such as generalised algebraic data types. While this paper is written as a literate Haskell [18] program using some minor extensions provided by GHC [23], the idea is applicable to other languages as well1 .

2

Deep Embedding

Consider the simple language of integer literals and addition. In deep embedding, terms in the language are represented by data in the host language. Hence, defining the constructs is as simple as creating the following algebraic data type2 . data Expr0 = Lit0 Int | Add0 Expr0 Expr0 Semantics are defined as functions on the Expr0 data type. For example, a function transforming the term to an integer—an evaluator—is implemented as follows. eval0 :: Expr0 → Int =e eval0 (Lit0 e) eval0 (Add0 e1 e2 ) = eval0 e1 + eval0 e2 Adding semantics—e.g. a printer—just means adding another function while the existing functions remain untouched. I.e. the key property of deep embedding. 1 2

Lubbers, M. (2022): Literate Haskell/lhs2TEX source code of the paper “Deep Embedding with Class”: TFP 2022. Zenodo. https://doi.org/10.5281/zenodo.6650880. All data types and functions are subscripted to indicate the evolution.

Deep Embedding with Class

41

The following function, transforming the Expr0 data type to a string, defines a simple printer for our language. print0 :: Expr0 → String = show v print0 (Lit0 v ) print0 (Add0 e1 e2 ) = "(" ++ print0 e1 ++ "-" ++ print0 e2 ++ ")" While the language is concise and elegant, it is not very expressive. Traditionally, extending the language is achieved by adding a case to the Expr0 data type. So, adding subtraction to the language results in the following revised data type. data Expr0 = Lit0 Int | Add0 Expr0 Expr0 | Sub0 Expr0 Expr0 Extending the DSL with language constructs exposes the Achilles’ heel of deep embedding. Adding a case to the data type means that all semantics functions have become partial and need to be updated to be able to handle this new case. This does not seem like an insurmountable problem, but it does pose a problem if either the functions or the data type itself are written by others or are contained in a closed library.

3

Shallow Embedding

Conversely, let us see how this would be done in shallow embedding. First, the data type is represented by functions in the host language with embedded semantics. Therefore, the evaluators for literals and addition both become a function in the host language as follows. type Sems = Int lits :: Int → Sems lits i = i adds :: Sems → Sems → Sems adds e1 e2 = e1 + e2 Adding constructions to the language is done by adding functions. Hence, the following function adds subtraction to our language. subs :: Sems → Sems → Sems subs e1 e2 = e1 − e2 Adding semantics on the other hand—e.g. a printer—is not that simple because the semantics are part of the functions representing the language constructs. One way to add semantics is to change all functions to execute both semantics at the same time. In our case this means changing the type of Sems to be (Int, String) so that all functions operate on a tuple containing the result of the evaluator and the printed representation at the same time. Alternatively, a single semantics can be defined that represents a fold over the language constructs [8], delaying the selection of semantics to the moment the fold is applied.

42

M. Lubbers

3.1

Tagless-Final Embedding

Tagless-final embedding overcomes the limitations of standard shallow embedding. To upgrade to this embedding technique, the language constructs are changed from functions to type classes. For our language this results in the following type class definition. class Exprt s where litt :: Int → s addt :: s → s → s Semantics become data types3 implementing these type classes, resulting in the following instance for the evaluator. newtype Evalt = Et Int instance Exprt Evalt where = Et v litt v addt (Et e1 ) (Et e2 ) = Et (e1 + e2 ) Adding constructs—e.g. subtraction—just results in an extra type class and corresponding instances. class Subt s where subt :: s → s → s instance Subt Evalt where subt (Et e1 ) (Et e2 ) = Et (e1 − e2 ) Finally, adding semantics such as a printer over the language is achieved by providing a data type representing the semantics accompanied by instances for the language constructs. newtype P rintert = Pt String instance Exprt P rintert where = Pt (show i ) litt i addt (Pt e1 ) (Pt e2 ) = Pt ("(" ++ e1 ++ "+" ++ e2 ++ ")") instance Subt P rintert where subt (Pt e1 ) (Pt e2 ) = Pt ("(" ++ e1 ++ "-" ++ e2 ++ ")") 3

In this case newtypes are used instead of regular data declarations. A newtype is a special data type with a single constructor containing a single value only to which it is isomorphic. It allows the programmer to define separate class instances that the instances of the isomorphic type without any overhead. During compilation the constructor is completely removed [18, Sect. 4.2.3].

Deep Embedding with Class

4

43

Lifting the Backends

Let us rethink the deeply embedded DSL design. Remember that in shallow embedding, the semantics are embedded in the language construct functions. Obtaining extensibility both in constructs and semantics was accomplished by abstracting the semantics functions to type classes, making the constructs overloaded in the semantics. In deep embedding, the constructs are embedded in the semantics functions instead. So, let us apply the same technique, i.e. make the semantics overloaded in the language constructs by abstracting the semantics functions to type classes. The same effect may be achieved when using similar techniques such as explicit dictionary passing or ML style modules. In our language this results in the following type class. class Eval1 v where eval1 :: v → Int data Expr1 = Lit1 Int | Add1 Expr1 Expr1 Implementing the semantics type class instances for the Expr1 data type is an elementary exercise. By a copy-paste and some modifications, we come to the following implementation. instance Eval1 Expr1 where =v eval1 (Lit1 v ) eval1 (Add1 e1 e2 ) = eval1 e1 + eval1 e2 Subtraction can now be defined in a separate data type, leaving the original data type intact. Instances for the additional semantics can now be implemented separately as instances of the type classes. data Sub1 = Sub1 Expr1 Expr1 instance Eval1 Sub1 where eval1 (Sub1 e1 e2 ) = eval1 e1 − eval1 e2

5

Existential Data Types

The astute reader might have noticed that we have dissociated ourselves from the original data type. It is only possible to create an expression with a subtraction on the top level. The recursive knot is left untied and as a result, Sub1 can never be reached from an Expr1 . Luckily, we can reconnect them by adding a special constructor to the Expr1 data type for housing extensions. It contains an existentially quantified [15] type with type class constraints [13,14] for all semantics type classes [23, Sect. 6.4.6] to allow it to house not just subtraction but any future extension.

44

M. Lubbers

data Expr2 = Lit2 Int | Add2 Expr2 Expr2 | forall x .Eval2 x ⇒ Ext2 x The implementation of the extension case in the semantics type classes is in most cases just a matter of calling the function for the argument as can be seen in the semantics instances shown below. instance Eval2 Expr2 where =v eval2 (Lit2 v ) eval2 (Add2 e1 e2 ) = eval2 e1 + eval2 e2 eval2 (Ext2 x ) = eval2 x Adding language construct extensions in different data types does mean that an extra Ext2 tag is introduced when using the extension. This burden can be relieved by creating a smart constructor for it that automatically wraps the extension with the Ext2 constructor so that it is of the type of the main data type. sub2 :: Expr2 → Expr2 → Expr2 sub2 e1 e2 = Ext2 (Sub2 e1 e2 ) In our example this means that the programmer can write4 : e2 :: Expr2 e2 = Lit2 42 ‘sub2 ‘ Lit2 1 instead of having to write e2 :: Expr2 e2 = Ext2 (Lit2 42 ‘Sub2 ‘ Lit2 1) 5.1

Unbraiding the Semantics from the Data

This approach does reveal a minor problem. Namely, that all semantics type classes are braided into our datatypes via the Ext2 constructor. Say if we add the printer again, the Ext2 constructor has to be modified to contain the printer type class constraint as well5 . Thus, if we add semantics, the main data type’s type class constraints in the Ext2 constructor need to be updated. To avoid this, the type classes can be bundled in a type class alias or type class collection as follows. class (Eval2 x , P rint2 x ) ⇒ Semantics2 x data Expr2 = Lit2 Int 4 5

Backticks are used to use functions or constructors in an infix fashion [18, Sect. 4.3.3]. Resulting in the following constructor: forall x .(Eval2 x , P rint2 x ) ⇒ Ext2 x .

Deep Embedding with Class

45

| Add2 Expr2 Expr2 | forall x .Semantics2 x ⇒ Ext2 x The class alias removes the need for the programmer to visit the main data type when adding additional semantics. Unfortunately, the compiler does need to visit the main data type again. Some may argue that adding semantics happens less frequently than adding language constructs but in reality it means that we have to concede that the language is not as easily extensible in semantics as in language constructs. More exotic type system extensions such as constraint kinds [3,25] can untangle the semantics from the data types by making the data types parametrised by the particular semantics. However, by adding some boilerplate, even without this extension, the language constructs can be parametrised by the semantics by putting the semantics functions in a data type. First the data types for the language constructs are parametrised by the type variable d as follows. Lit3 Int data Expr3 d = | Add3 (Expr3 d ) (Expr3 d ) | forall x .Ext3 (d x ) x data Sub3 d = Sub3 (Expr3 d ) (Expr3 d ) The d type variable is inhabited by an explicit dictionary for the semantics, i.e. a witness to the class instance. Therefore, for all semantics type classes, a data type is made that contains the semantics function for the given semantics. This means that for Eval3 , a dictionary with the function EvalDict3 is defined, a type class HasEval3 for retrieving the function from the dictionary and an instance for HasEval3 for EvalDict3 . newtype EvalDict3 v = EvalDict3 (v → Int) class HasEval3 d where getEval3 :: d v → v → Int instance HasEval3 EvalDict3 where getEval3 (EvalDict3 e) = e The instances for the type classes change as well according to the change in the datatype. Given that there is a HasEval3 instance for the witness type d , we can provide an implementation of Eval3 for Expr3 d . instance HasEval3 d ⇒ Eval3 (Expr3 d ) where =v eval3 (Lit3 v ) eval3 (Add3 e1 e2 ) = eval3 e1 + eval3 e2 eval3 (Ext3 d x ) = getEval3 d x instance HasEval3 d ⇒ Eval3 (Sub3 d ) where eval3 (Sub3 e1 e2 ) = eval3 e1 − eval3 e2

46

M. Lubbers

Because the Ext3 constructor from Expr3 now contains a value of type d , the smart constructor for Sub3 must somehow come up with this value. To achieve this, a type class is introduced that allows the generation of such a dictionary. class GDict a where gdict :: a This type class has individual instances for all semantics dictionaries, linking the class instance to the witness value. I.e. if there is a type class instance known, a witness value can be conjured using the gdict function. instance Eval3 v ⇒ GDict (EvalDict3 v ) where gdict = EvalDict3 eval3 With these instances, the semantics function can be retrieved from the Ext3 constructor and in the smart constructors they can be generated as follows: sub3 :: GDict (d (Sub3 d )) ⇒ Expr3 d → Expr3 d → Expr3 d sub3 e1 e2 = Ext3 gdict (Sub3 e1 e2 ) Finally, we reached the end goal, orthogonal extension of both language constructs as shown by adding subtraction to the language and in language semantics. Adding the printer can now be done without touching the original code as follows. First the printer type class, dictionaries and instances for GDict are defined. class P rint3 v where print3 :: v → String newtype P rintDict3 v = P rintDict3 (v → String) class HasP rint3 d where getP rint3 :: d v → v → String instance HasP rint3 P rintDict3 where getP rint3 (P rintDict3 e) = e instance P rint3 v ⇒ GDict (P rintDict3 v ) where gdict = P rintDict3 print3 Then the instances for P rint3 of all the language constructs can be defined. instance HasP rint3 d ⇒ P rint3 (Expr3 d ) where print3 (Lit3 v ) = show v print3 (Add3 e1 e2 ) = "(" ++ print3 e1 ++ "+" ++ print3 e2 ++ ")" print3 (Ext3 d x ) = getP rint3 d x instance HasP rint3 d ⇒ P rint3 (Sub3 d ) where print3 (Sub3 e1 e2 ) = "(" ++ print3 e1 ++ "-" ++ print3 e2 ++ ")"

Deep Embedding with Class

6

47

Transformation Semantics

Most semantics convert a term to some final representation and can be expressed just by functions on the cases. However, the implementation of semantics such as transformation or optimisation may benefit from a so-called intentional analysis of the abstract syntax tree. In shallow embedding, the implementation for these types of semantics is difficult because there is no tangible abstract syntax tree. In off-the-shelf deep embedding this is effortless since the function can pattern match on the constructor or structures of constructors. To demonstrate intensional analyses in classy deep embedding we write an optimizer that removes addition and subtraction by zero. In classy deep embedding, adding new semantics means first adding a new type class housing the function including the machinery for the extension constructor. class Opt3 v where opt3 :: v → v newtype OptDict3 v = OptDict3 (v → v ) class HasOpt3 d where getOpt3 :: d v → v → v instance HasOpt3 OptDict3 where getOpt3 (OptDict3 e) = e instance Opt3 v ⇒ GDict (OptDict3 v ) where gdict = OptDict3 opt3 The implementation of the optimizer for the Expr3 data type is no complicated task. The only interesting bit occurs in the Add3 constructor, where we pattern match on the optimised children to determine whether an addition with zero is performed. If this is the case, the addition is removed. instance HasOpt3 d ⇒ Opt3 (Expr3 d ) where = Lit3 v opt3 (Lit3 v ) opt3 (Add3 e1 e2 ) = case (opt3 e1 , opt3 e2 ) of ) → e2 (Lit3 0, e2  (e1 , Lit3 0) → e1  (e1 , e2 ) → Add3 e1 e2 opt3 (Ext3 d x ) = Ext3 d (getOpt3 d x ) Replicating this for the Opt3 instance of Sub3 seems a clear-cut task at first glance. instance HasOpt3 d ⇒ Opt3 (Sub3 d ) where opt3 (Sub3 e1 e2 ) = case (opt3 e1 , opt3 e2 ) of (e1 , Lit3 0) → e1 (e1 , e2 ) → Sub3 e1 e2 Unsurprisingly, this code is rejected by the compiler. When a literal zero is matched as the right-hand side of a subtraction, the left-hand side of type Expr3

48

M. Lubbers

is returned. However, the type signature of the function dictates that it should be of type Sub3 . To overcome this problem we add a convolution constructor. 6.1

Convolution

Adding a loopback case or convolution constructor to Sub3 allows the removal of the Sub3 constructor while remaining the Sub3 type. It should be noted that a loopback case is only required if the transformation actually removes tags. This changes the Sub3 data type as follows. data Sub4 d = Sub4 (Expr4 d ) (Expr4 d ) | SubLoop4 (Expr4 d ) instance HasEval4 d ⇒ Eval4 (Sub4 d ) where eval4 (Sub4 e1 e2 ) = eval4 e1 − eval4 e2 eval4 (SubLoop4 e1 ) = eval4 e1 With this loopback case in the toolbox, the following Sub instance optimises away subtraction with zero literals. instance HasOpt4 d ⇒ Opt4 (Sub4 d ) where opt4 (Sub4 e1 e2 ) = case (opt4 e1 , opt4 e2 ) of (e1 , Lit4 0) → SubLoop4 e1 (e1 , e2 ) → Sub4 e1 e2 opt4 (SubLoop4 e) = SubLoop4 (opt4 e)

6.2

Pattern Matching

Pattern matching within datatypes and from an extension to the main data type works out of the box. Cross-extensional pattern matching on the other hand— matching on a particular extension—is something that requires a bit of extra care. Take for example negation propagation and double negation elimination. Pattern matching on values with an existential type is not possible without leveraging dynamic typing [1,2]. To enable dynamic typing support, the Typeable type class as provided by Data.Dynamic [22] is added to the list of constraints in all places where we need to pattern match across extensions. As a result, the Typeable type class constraints are added to the quantified type variable x of the Ext4 constructor and to d s in the smart constructors. data Expr4 d = Lit4 Int | Add4 (Expr4 d ) (Expr4 d ) | forall x .Typeable x ⇒ Ext4 (d x ) x First let us add negation to the language by defining a datatype representing it. Negation elimination requires the removal of negation constructors, so a convolution constructor is defined as well.

Deep Embedding with Class

49

data N eg4 d = N eg4 (Expr4 d ) | N egLoop4 (Expr4 d ) neg4 :: (Typeable d , GDict (d (N eg4 d ))) ⇒ Expr4 d → Expr4 d neg4 e = Ext4 gdict (N eg4 e) The evaluation and printer instances for the N eg4 datatype are defined as follows. instance HasEval4 d ⇒ Eval4 (N eg4 d ) where e) = negate (eval4 e) eval4 (N eg4 eval4 (N egLoop4 e) = eval4 e instance HasP rint4 d ⇒ P rint4 (N eg4 d ) where e) = "(~" ++ print4 e ++ ")" print4 (N eg4 print4 (N egLoop4 e) = print4 e The Opt4 instance contains the interesting bit. If the sub expression of a negation is an addition, negation is propagated downwards. If the sub expression is again a negation, something that can only be found out by a dynamic pattern match, it is replaced by a N egLoop4 constructor. instance (Typeable d , GDict (d (N eg4 d )), HasOpt4 d ) ⇒ Opt4 (N eg4 d ) where opt4 (N eg4 (Add4 e1 e2 )) = N egLoop4 (Add4 (opt4 (neg4 e1 )) (opt4 (neg4 e2 ))) opt4 (N eg4 (Ext4 d x )) = case fromDynamic (toDyn (getOpt4 d x )) of Just (N eg4 e) → N egLoop4 e → N eg4 (Ext4 d (getOpt4 d x )) e) = N eg4 (opt4 e) opt4 (N eg4 opt4 (N egLoop4 e) = N egLoop4 (opt4 e) Loopback cases do make cross-extensional pattern matching less modular in general. For example, Ext4 d (SubLoop4 (Lit4 0)) is equivalent to Lit4 0 in the optimisation semantics and would require an extra pattern match. Fortunately, this problem can be mitigated—if required—by just introducing an additional optimisation semantics that removes loopback cases. Luckily, one does not need to resort to these arguably blunt matters often. Dependent language functionality often does not need to span extensions, i.e. it is possible to group them in the same data type. 6.3

Chaining Semantics

Now that the data types are parametrised by the semantics a final problem needs to be overcome. The data type is parametrised by the semantics, thus, using multiple semantics, such as evaluation after optimising is not straightforwardly

50

M. Lubbers

possible. Luckily, a solution is readily at hand: introduce an ad-hoc combination semantics. data OptP rintDict4 v = OP D4 (OptDict4 v ) (P rintDict4 v ) instance HasOpt4 OptP rintDict4 where getOpt4 (OP D4 v ) = getOpt4 v instance HasP rint4 OptP rintDict4 where getP rint4 (OP D4 v ) = getP rint4 v instance (Opt4 v , P rint4 v ) ⇒ GDict (OptP rintDict4 v ) where gdict = OP D4 gdict gdict And this allows us to write print4 (opt4 e1 ) resulting in "((~42)+(~38))" when e1 represents (∼ (42 + 38)) − 0 and is thus defined as follows. e1 :: Expr4 OptP rintDict4 e1 = neg4 (Lit4 42 ‘Add4 ‘ Lit4 38) ‘sub4 ‘ Lit4 0 When using classy deep embedding to the fullest, the ability of the compiler to infer very general types expires. As a consequence, defining reusable expressions that are overloaded in their semantics requires quite some type class constraints that cannot be inferred by the compiler (yet) if they use many extensions. Solving this remains future work. For example, the expression ∼ (42 − 38) + 1 has to be defined as: e3 :: (Typeable d , GDict (d (N eg4 d )) , GDict (d (Sub4 d ))) ⇒ Expr4 d e3 = neg4 (Lit4 42 ‘sub4 ‘ Lit4 38) ‘Add4 ‘ Lit4 1

7

Generalised Algebraic Data Types

Generalised algebraic data types (GADTs) are enriched data types that allow the type instantiation of the constructor to be explicitly defined [7,9]. Leveraging GADTs, deeply embedded DSLs can be made statically type safe even when different value types are supported. Even when GADTs are not supported natively in the language, they can be simulated using embedding-projection pairs or equivalence types [6, Sect. 2.2]. Where some solutions to the expression problem do not easily generalise to GADTs (see Sect. 9), classy deep embedding does. Generalising the data structure of our DSL is fairly straightforward and to spice things up a bit, we add an equality and boolean not language construct. To make the existing DSL constructs more general, we relax the types of those constructors. For example, operations on integers now work on all numerals instead. Moreover, the Litg constructor can be used to lift values of any type to the DSL domain as long as they have a Show instance, required for the printer. Since some optimisations on N otg remove constructors and therefore use cross-extensional

Deep Embedding with Class

51

pattern matches, Typeable constraints are added to a. Furthermore, because the optimisations for Addg and Subg are now more general, they do not only work for Ints but for any type with a Num instance, the Eq constraint is added to these constructors as well. Finally, not to repeat ourselves too much, we only show the parts that substantially changed. The omitted definitions and implementation can be found in Appendix A. data Exprg d a where :: Show a ⇒ a → Exprg d a Litg :: (Eq a, Num a) ⇒ Exprg d a → Exprg d a → Exprg d a Addg :: Typeable x ⇒ d x → x a → Exprg d a Extg data N egg d a where :: (Typeable a, Num a) ⇒ Exprg d a → N egg d a N egg Exprg d a → N egg d a N egLoopg :: data N otg d a where :: Exprg d Bool → N otg d Bool N otg N otLoopg :: Exprg d a → N otg d a The smart constructors for the language extensions inherit the class constraints of their data types and include a Typeable constraint on the d type variable for it to be usable in the Extg constructor as can be seen in the smart constructor for N egg : negg :: (Typeable d , GDict (d (N egg d )), Typeable a, Num a) ⇒ Exprg d a → Exprg d a negg e = Extg gdict (N egg e) notg :: (Typeable d , GDict (d (N otg d ))) ⇒ Exprg d Bool → Exprg d Bool notg e = Extg gdict (N otg e) Upgrading the semantics type classes to support GADTs is done by an easy textual search and replace. All occurrences of v are now parametrised by type variable a: class Evalg v where evalg :: v a → a class P rintg v where printg :: v a → String class Optg v where optg :: v a → v a Now that the shape of the type classes has changed, the dictionary data types and the type classes need to be adapted as well. The introduced type variable a is not an argument to the type class, so it should not be an argument to the dictionary data type. To represent this type class function, a rank-2 polymorphic function is needed [23, Sect. 6.4.15] [17]. Concretely, for the evaluatior this results in the following definitions:

52

M. Lubbers

newtype EvalDictg v = EvalDictg (forall a.v a → a) class HasEvalg d where getEvalg :: d v → v a → a instance HasEvalg EvalDictg where getEvalg (EvalDictg e) = e The GDict type class is general enough, so the instances can remain the same. The Evalg instance of GDict looks as follows: instance Evalg v ⇒ GDict (EvalDictg v ) where gdict = EvalDictg evalg Finally, the implementations for the instances can be ported without complication show using the optimisation instance of N otg : instance (Typeable d , GDict (d (N otg d )), HasOptg d ) ⇒ Optg (N otg d ) where optg (N otg (Extg d x )) = case fromDynamic (toDyn (getOptg d x )) :: Maybe (N otg d Bool ) of Just (N otg e) → N otLoopg e → N otg (Extg d (getOptg d x )) = N otg (optg e) optg (N otg e) optg (N otLoopg e) = N otLoopg (optg e)

8

Conclusion

Classy deep embedding is a novel organically grown embedding technique that alleviates deep embedding from the extensibility problem in most cases. By abstracting the semantics functions to type classes they become overloaded in the language constructs. Thus, making it possible to add new language constructs in a separate type. These extensions are brought together in a special extension constructor residing in the main data type. This extension case is overloaded by the language construct using a data type containing the class dictionary. As a result, orthogonal extension is possible for language constructs and semantics using only little syntactic overhead or type annotations. The basic technique only requires—well established through history and relatively standard—existential data types. However, if needed, the technique generalises to GADTs as well, adding rank-2 types to the list of type system requirements as well. Finally, the abstract syntax tree remains observable which makes it suitable for intensional analyses, albeit using occasional dynamic typing for truly cross-extensional transformations. Defining reusable expressions overloaded in semantics or using multiple semantics on a single expression requires some boilerplate still, getting around this remains future work.

Deep Embedding with Class

9

53

Related Work

Embedded DSL techniques in functional languages have been a topic of research for many years, thus we do not claim a complete overview of related work. Clearly, classy deep embedding bears most similarity to the Datatypes ` a la Carte [21]. In Swierstra’s approach, semantics are lifted to type classes similarly to classy deep embedding. Each language construct is their own datatype parametrised by a type parameter. This parameter contains some type level representation of language constructs that are in use. In classy deep embedding, extensions do not have to be enumerated at the type level but are captured in the extension case. Because all the constructs are expressed in the type system, nifty type system tricks need to be employed to convince the compiler that everything is type safe and the class constraints can be solved. Furthermore, it requires some boilerplate code such as functor instances for the data types. In return, pattern matching is easier and does not require dynamic typing. Classy deep embedding only strains the programmer with writing the extension case for the main data type and the occasional loopback constructor. L¨oh and Hinze proposed a language extension that allows open data types and open functions, i.e. functions and data types that can be extended with more cases later on [12]. They hinted at the possibility of using type classes for open functions but had serious concerns that pattern matching would be crippled because constructors are becoming types, thus ultimately becoming impossible to type. In contrast, this paper shows that pattern matching is easily attainable—albeit using dynamic types—and that the terms can be typed without complicated type system extensions. A technique similar to classy deep embedding was proposed by Najd and Peyton Jones to tackle a slightly different problem, namely that of reusing a data type for multiple purposes in a slightly different form [16]. For example to decorate the abstract syntax tree of a compiler differently for each phase of the compiler. They propose to add an extension descriptor as a type variable to a data type and a type family that can be used to decorate constructors with extra information and add additional constructors to the data type using an extension constructor. Classy deep embedding works similarly but uses existentially quantified type variables to describe possible extensions instead of type variables and type families. In classy deep embedding, the extensions do not need to be encoded in the type system and less boilerplate is required. Furthermore, pattern matching on extensions becomes a bit more complicated but in return it allows for multiple extensions to be added orthogonally and avoids the necessity for type system extensions. Tagless-final embedding is the shallowly embedded counterpart of classy deep embedding and was invented for the same purpose; overcoming the issues with standard shallow embedding [5]. Classy deep embedding was organically grown from observing the evolution of tagless-final embedding. The main difference between tagless-final embedding and classy deep embedding—and in general between shallow and deep embedding—is that intensional analyses of

54

M. Lubbers

the abstract syntax tree is more difficult because there is no tangible abstract syntax tree data structure. In classy deep embedding, it is possible to define transformations even across extensions. Hybrid approaches between deep and shallow embedding exist as well. For example, Svenningson et al. show that by expressing the deeply embedded language in a shallowly embedded core language, extensions can be made orthogonally as well [20]. This paper differs from those approaches in the sense that it does not require a core language in which all extensions need to be expressible. Acknowledgements. This research is partly funded by the Royal Netherlands Navy. Furthermore, I would like to thank Pieter and Rinus for the fruitful discussions, Ralf for inspiring me to write a functional pearl, and the anonymous reviewers for their valuable and honest comments.

A

Appendix

A.1

Data Type Definitions

data Subg d a where :: (Eq a, Num a) ⇒ Exprg Subg Exprg SubLoopg :: data Eqg d a where :: (Typeable a, Eq a) ⇒ Exprg Eqg EqLoopg :: Exprg A.2

d a → Exprg d a → Subg d a d a → Subg d a d a → Exprg d a → Eqg d Bool d a → Eqg d a

Smart Constructors

subg :: (Typeable d , GDict (d (Subg d )), Eq a, Num a) ⇒ Exprg d a → Exprg d a → Exprg d a subg e1 e2 = Extg gdict (Subg e1 e2 ) eqg :: (Typeable d , GDict (d (Eqg d )), Eq a, Typeable a) ⇒ Exprg d a → Exprg d a → Exprg d Bool eqg e1 e2 = Extg gdict (Eqg e1 e2 ) A.3

Semantics Classes and Data Types

newtype P rintDictg v = P rintDictg (forall a.v a → String) class HasP rintg d where getP rintg :: d v → v a → String instance HasP rintg P rintDictg where getP rintg (P rintDictg e) = e

Deep Embedding with Class

newtype OptDictg v = OptDictg (forall a.v a → v a) class HasOptg d where getOptg :: d v → v a → v a instance HasOptg OptDictg where getOptg (OptDictg e) = e

A.4

GDict instances

instance P rintg v ⇒ GDict (P rintDictg v ) where gdict = P rintDictg printg instance Optg v ⇒ GDict (OptDictg v ) where gdict = OptDictg optg A.5

Evaluator Instances

instance HasEvalg d ⇒ Evalg (Exprg d ) where =v evalg (Litg v ) evalg (Addg e1 e2 ) = evalg e1 + evalg e2 evalg (Extg d x ) = getEvalg d x instance HasEvalg d ⇒ Evalg (Subg d ) where e1 e2 ) = evalg e1 − evalg e2 evalg (Subg evalg (SubLoopg e) = evalg e instance HasEvalg d ⇒ Evalg (N egg d ) where e) = negate (evalg e) evalg (N egg evalg (N egLoopg e) = evalg e instance HasEvalg d ⇒ Evalg (Eqg d ) where evalg (Eqg e1 e2 ) = evalg e1 ≡ evalg e2 evalg (EqLoopg e) = evalg e instance HasEvalg d ⇒ Evalg (N otg d ) where = not (evalg e) evalg (N otg e) evalg (N otLoopg e) = evalg e A.6

Printer Instances

instance HasP rintg d ⇒ P rintg (Exprg d ) where = show v printg (Litg v ) printg (Addg e1 e2 ) = "(" ++ printg e1 ++ "+" ++ printg e2 ++ ")" printg (Extg d x ) = getP rintg d x

55

56

M. Lubbers

instance HasP rintg d ⇒ P rintg (Subg d ) where printg (Subg e1 e2 ) = "(" ++ printg e1 ++ "-" ++ printg e2 ++ ")" printg (SubLoopg e) = printg e instance HasP rintg d ⇒ P rintg (N egg d ) where printg (N egg e) = "(negate " ++ printg e ++ ")" printg (N egLoopg e) = printg e instance HasP rintg d ⇒ P rintg (Eqg d ) where printg (Eqg e1 e2 ) = "(" ++ printg e1 ++ "==" ++ printg e2 ++ ")" printg (EqLoopg e) = printg e instance HasP rintg d ⇒ P rintg (N otg d ) where = "(not " ++ printg e ++ ")" printg (N otg e) printg (N otLoopg e) = printg e A.7

Optimisation Instances

instance HasOptg d ⇒ Optg (Exprg d ) where = Litg v optg (Litg v ) optg (Addg e1 e2 ) = case (optg e1 , optg e2 ) of ) → e2 (Litg 0, e2  (e1 , Litg 0) → e1  (e1 , e2 ) → Addg e1 e2 optg (Extg d x ) = Extg d (getOptg d x ) instance HasOptg d ⇒ Optg (Subg d ) where e1 e2 ) = case (optg e1 , optg e2 ) of optg (Subg (e1 , Litg 0) → SubLoopg e1 (e1 , e2 ) → Subg e1 e2 optg (SubLoopg e) = SubLoopg (optg e) instance (Typeable d , GDict (d (N egg d )), HasOptg d ) ⇒ Optg (N egg d ) where optg (N egg (Addg e1 e2 )) = N egLoopg (Addg (optg (negg e1 )) (optg (negg e2 ))) optg (N egg (Extg d x )) = case fromDynamic (toDyn (getOptg d x )) of Just (N egg e) → N egLoopg e → N egg (Extg d (getOptg d x )) optg (N egg e) = N egg (optg e) optg (N egLoopg e) = N egLoopg (optg e) instance HasOptg d ⇒ Optg (Eqg d ) where e1 e2 ) = Eqg (optg e1 ) (optg e2 ) optg (Eqg = EqLoopg (optg e) optg (EqLoopg e)

Deep Embedding with Class

57

References 1. Abadi, M., Cardelli, L., Pierce, B., Plotkin, G.: Dynamic typing in a statically typed language. ACM Trans. Program. Lang. Syst. 13(2), 237–268 (1991). https://doi. org/10.1145/103135.103138 2. Baars, A.I., Swierstra, S.D.: Typing dynamic typing. In: Proceedings of the Seventh ACM SIGPLAN International Conference on Functional Programming, pp. 157– 166. ICFP 2002, Association for Computing Machinery, New York (2002). https:// doi.org/10.1145/581478.581494 3. Bolingbroke, M.: Constraint kinds for GHC (2011). http://blog.omega-prime.co. uk/2011/09/10/constraint-kinds-for-ghc/. Blog post. Accessed 09 Sep 2021 4. Boulton, R., Gordon, A., Gordon, M., Harrison, J., Herbert, J., Tassel, J.V.: Experience with embedding hardware description languages in HOL. In: Stavridou, V., Melham, T.F., Boute, R.T. (eds.) IFIP TC10/WG, vol. 10, pp. 129–156. Elsevier, Amsterdam, NL (1992). Event-place: Nijmegen, NL 5. Carette, J., Kiselyov, O., Shan, C.C.: Finally tagless, partially evaluated: tagless staged interpreters for simpler typed languages. J. Funct. Program. 19(5), 509–543 (2009). https://doi.org/10.1017/S0956796809007205 6. Cheney, J., Hinze, R.: A lightweight implementation of generics and dynamics. In: Proceedings of the 2002 ACM SIGPLAN Workshop on Haskell, pp. 90–104. Association for Computing Machinery, Pittsburgh, PA (2002). https://doi.org/10. 1145/581690.581698 7. Cheney, J., Hinze, R.: First-class phantom types. Tech. Rep. TR2003-1901, Cornell University (2003). https://ecommons.cornell.edu/handle/1813/5614 8. Gibbons, J., Wu, N.: Folding domain-specific languages: deep and shallow embeddings (functional pearl). In: Proceedings of the 19th ACM SIGPLAN International Conference on Functional Programming, pp. 339–347. ICFP 2014, Association for Computing Machinery, New York (2014). https://doi.org/10.1145/2628136. 2628138 9. Hinze, R.: Fun with phantom types. In: Gibbons, J., de Moor, O. (eds.) The Fun of Programming, pp. 245–262. Bloomsbury Publishing, Palgrave, Cornerstones of Computing (2003) 10. Kiselyov, O.: Typed tagless final interpreters. In: Gibbons, J. (ed.) Generic and Indexed Programming. LNCS, vol. 7470, pp. 130–174. Springer, Heidelberg (2012). https://doi.org/10.1007/978-3-642-32202-0 3 11. Krishnamurthi, S., Felleisen, M., Friedman, D.P.: Synthesizing object-oriented and functional design to promote re-use. In: Jul, E. (ed.) ECOOP 1998. LNCS, vol. 1445, pp. 91–113. Springer, Heidelberg (1998). https://doi.org/10.1007/BFb0054088 12. L¨ oh, A., Hinze, R.: Open data types and open functions. In: Proceedings of the 8th ACM SIGPLAN International Conference on Principles and Practice of Declarative Programming, pp. 133–144. PPDP 2006, Association for Computing Machinery, New York (2006). https://doi.org/10.1145/1140335.1140352 13. L¨ aufer, K.: Combining type classes and existential types. In: Proceedings of the Latin American Informatic Conference (PANEL). ITESM-CEM, Monterrey, Mexico (1994) 14. L¨ aufer, K.: Type classes with existential types. J. Funct. Program. 6(3), 485–518 (1996). https://doi.org/10.1017/S0956796800001817 15. Mitchell, J.C., Plotkin, G.D.: Abstract types have existential type. ACM Trans. Program. Lang. Syst. 10(3), 470–502 (1988). https://doi.org/10.1145/44501.45065

58

M. Lubbers

16. Najd, S., Peyton Jones, S.: Trees that grow. J. Univ. Comput. Sci. 23(1), 42–62 (2017) 17. Odersky, M., L¨ aufer, K.: Putting type annotations to work. In: Proceedings of the 23rd ACM SIGPLAN-SIGACT Symposium on Principles of Programming Languages, pp. 54–67. POPL 1996, Association for Computing Machinery, New York (1996). https://doi.org/10.1145/237721.237729 18. Peyton Jones, S. (ed.): Haskell 98 Language and Libraries: The Revised Report. Cambridge University Press, Cambridge (2003) 19. Reynolds, J.C.: User-defined types and procedural data structures as complementary approaches to data abstraction. In: Gries, D. (ed.) Programming Methodology: A Collection of Articles by Members of IFIP WG2.3, pp. 309–317. Springer, New York (1978). https://doi.org/10.1007/978-1-4612-6315-9 22 20. Svenningsson, J., Axelsson, E.: Combining deep and shallow embedding for EDSL. In: Loidl, H.-W., Pe˜ na, R. (eds.) TFP 2012. LNCS, vol. 7829, pp. 21–36. Springer, Heidelberg (2013). https://doi.org/10.1007/978-3-642-40447-4 2 21. Swierstra, W.: Data types ` a la carte. J. Funct. Program. 18(4), 423–436 (2008). https://doi.org/10.1017/S0956796808006758 22. Team, G.: Data. Dynamic (2021). https://hackage.haskell.org/package/base-4.14. 1.0/docs/Data-Dynamic.html. Accessed 24 Feb 2021 23. Team, G.: GHC User’s Guide Documentation (2021). https://downloads.haskell. org/∼ghc/latest/docs/users%20guide.pdf. Accessed 24 Feb 2021 24. Wadler, P.: The expression problem (1998-11-12). https://homepages.inf.ed.ac.uk/ wadler/papers/expression/expression.txt. Accessed 24 Feb 2021 25. Yorgey, B.A., Weirich, S., Cretin, J., Peyton Jones, S., Vytiniotis, D., Magalh˜ aes, J.P.: Giving haskell a promotion. In: Proceedings of the 8th ACM SIGPLAN Workshop on Types in Language Design and Implementation, pp. 53–66. TLDI 2012, Association for Computing Machinery, New York (2012). https://doi.org/10.1145/ 2103786.2103795

Understanding Algebraic Effect Handlers via Delimited Control Operators Youyou Cong1(B) 1

and Kenichi Asai2

Tokyo Institute of Technology, Tokyo, Japan 2

[email protected]

Ochanomizu University, Tokyo, Japan

[email protected] https://prg.is.titech.ac.jp/people/cong/, http://pllab.is.ocha.ac.jp/~asai/

Abstract. Algebraic effects and handlers are a powerful and convenient abstraction for user-defined effects. We aim to understand effect handlers through the lens of control operators, a similar but more well-studied tool for expressing effects. In this paper, we establish two program transformations and a type system for effect handlers, all by reusing the existing results about control operators and their relationship to effect handlers. Keywords: Algebraic effects and handlers · Delimited control operators · Macro translation · CPS translation · Type systems

1

Introduction

Algebraic effects [36] and handlers [37] have become an essential element of a programmer’s toolbox. Effect handlers provide a convenient interface for defining and composing effects. They also enable concise implementation of sophisticated behavior by giving the programmer access to continuations. Over the past decade, researchers have been actively studying the theory of effect handlers. As an outcome of these studies, we have obtained various program transformations for effect handlers, which can be used to compile effect handlers into plain λ-terms [17,40,42]. There are also a variety of type systems for effect handlers, in which effects are represented as sets [3], rows [14], or capabilities [8]. We continue the study of effect handlers, but from a different point of view. Instead of directly developing the theory of effect handlers, we derive it from the theory of delimited control operators [10,11,18,29]. Control operators have a longer history than effect handlers, and their theory is closely connected to that of effect handlers [13,34]. We aim to improve the understanding of effect handlers by exploiting the existing results about control operators, as well as the connection between effect handlers and control operators. In this paper, we discuss a variant of control operators known as shift0 and dollar [30], and a variant of effect handlers that are called deep handlers [19]. Our goal is to answer the following research questions. c Springer Nature Switzerland AG 2022  W. Swierstra and N. Wu (Eds.): TFP 2022, LNCS 13401, pp. 59–79, 2022. https://doi.org/10.1007/978-3-031-21314-4_4

60

Y. Cong and K. Asai

– The dollar operator extends the more traditional reset0 operator with a return clause, and this extension is known to cause no change in the expressiveness [30]. Does this result also apply to effect handlers with and without a return clause? – The shift0 and dollar operators are associated with a CPS translation that can be viewed as a definitional interpreter. What CPS translation can we derive for deep effect handlers from the CPS translation for shift0 and dollar? – The typing of shift0 and dollar is directed by their CPS translation [9,18, 29], rather than their direct-style form. What type system can we derive for deep effect handlers using the CPS approach? Note that we do not intend to produce results that are particularly surprising, nor do we aim to develop something that has immediate practical use. Instead, we would like to explore the possibility of transferring knowledge between different control facilities. This method can be applied for various purposes, and we feel that it is especially effective when used in a pedagogical context. In the rest of this paper, we first define a calculus of shift0/dollar (Sect. 2) and another calculus of deep effect handlers (Sect. 3). We next answer the three questions one by one (Sects. 4 to 6). We then discuss related work (Sect. 7) and conclude with future perspectives (Sect. 8).

2

λS0 : A Calculus of Shift0 and Dollar

As a calculus of control operators, we consider a minor variation of Forster et al.’s calculus of shift0 and dollar [13], which we call λS0 . In Fig. 1, we present the syntax and reduction rules of λS0 . The calculus differs from that of Forster et al. in that it is formalized as a fine-grain call-by-value calculus [26] instead of call-by-push-value [25]. This means (i) functions are classified as values; and (ii) computations must be explicitly sequenced using the let expression. The fine-grain syntax simplifies the CPS translation and type system developed in later sections. Among the control constructs, S0 k. M (pronounced “shift”) captures a continuation surrounding itself. The other construct M | x.N  (pronounced “dollar”) computes the main computation M in a delimited context that ends with the continuation N 1 . There are two reduction rules for the control constructs. If the main computation of dollar evaluates to a value V , the whole expression evaluates to the ending continuation N with V bound to x (rule β$ ). If the main computation of dollar evaluates to F [S0 k. M ], where F is a pure evaluation context that has 1

The dollar operator was originally proposed by Kiselyov and Shan [20]. In their formalization, the construct takes the form N $ M , where M is the main computation and N is an arbitrary expression representing an ending continuation. We use the bracket notation of Forster et al., restricting N to be an abstraction λx. N . This makes it easier to compare dollar with effect handlers.

Understanding Algebraic Effect Handlers via Delimited Control Operators

61

Fig. 1. Syntax and reduction rules of λS0

no dollar surrounding a hole, the whole expression evaluates to M with k being the captured continuation λy. F [return y] | x.N  (rule βS0 ). Notice that the continuation includes the dollar construct that was originally surrounding the shift0 operator. This design is shared with the shift operator of Danvy and Filinski [10]. Notice next that the body of shift0 is evaluated without being surrounded by the original dollar. This differentiates shift0 from shift, and allows shift0 to capture a meta-context 2 , i.e., a context that resides outside of the lexically closest dollar.

3

λh : A Calculus of Effect Handlers

As a calculus of effect handlers, we consider a restricted variant of Hillerström et al.’s calculus of deep handlers [17], which we call λh . In Fig. 2, we present the syntax and reduction rules of λh . The calculus differs from Hillerström et al.’s in that it features unlabeled operations. This means handlers in λh can only handle a single operation. The restriction helps us concentrate on the connection to the λS0 calculus. 2

To see what a meta-context is, consider the following program: 2 ∗ 1 + S0 k1 . S0 k2 . k2 3 | x.return x | x.return x ∗ 6 The first shift0 operator captures the delimited context 1 + [] | x.return x, whereas the second one captures the meta-context 2 ∗ [] | x.return x surrounding the innermost dollar operator.

62

Y. Cong and K. Asai

Fig. 2. Syntax and reduction rules of λh

Among the effect constructs, do V performs an operation with argument V . The other construct handle M with {x. Mr ; x, k. Mh } computes the main computation M in a delimited context, and handles the result of M using the return clause Mr and the operation clause Mh . There are again two reduction rules for the effect constructs. If the main computation of a handler evaluates to a value V , the whole expression evaluates to the return clause N with V bound to x (rule βh ). If the main computation evaluates to F [do V ], where F is a pure evaluation context that has no handler surrounding a hole, the whole expression evaluates to the operation clause Mh , with x being V and k being the captured continuation (often called “resumption” in the effect handlers literature) λy. handle F [return y] with {x. Mr ; x, k. Mh } (rule βdo ). Notice that the continuation includes the handler that was originally surrounding the operation. This design is shared with shift0, and characterizes handlers in λh as deep ones [19]. Notice next that the operation clause is evaluated without being surrounded by the original handler. This is another similarity to shift0, and allows handlers to capture a metacontext.

Understanding Algebraic Effect Handlers via Delimited Control Operators

4

63

Adding and Removing the Return Clause

The dollar construct M | x.N  in λS0 is a generalization of the “reset” construct M , which is more commonly found in the continuations literature. The reset construct does not have an ending continuation; it simply evaluates the body M in an empty context. As shown by Materzok and Biernacki [28], the dollar and reset constructs can macro-express [12] each other. That is, there is a pair of local translations, called macro translations, that add and remove the ending continuation while preserving the meaning of the program. It is easy to see that the return clause of an effect handler plays a similar role to the ending continuation of dollar. Thus, we can naturally consider a variant of effect handlers without the return clause. Such handlers are not uncommon in formalizations [40,42,44] as they simplify the reduction and typing rules. Now the reader might wonder: Does the existence of the return clause affects the expressiveness of an effect handler calculus? In this section, we define macro translations between handlers with and without the return clause. Among different ways of removing the return clause, we take a relatively intuitive approach that relies on a labeling mechanism. In what follows, we review the macro translations between dollar and reset (Sect. 4.1), then adapt the translations to effect handlers (Sect. 4.2), and lastly prove the correctness of the translations (Sect. 4.3). 4.1

Translating Between Dollar and Reset0

Materzok and Biernacki [30] define the macro translations _m between dollar and reset0 as follows3 . From dollar to reset M | x.N m = let y = M m in S0 z. (λx. N m ) y From reset to dollar M m = M m | x.return x The translation from reset to dollar is straightforward: it simply adds a trivial ending continuation. The translation from dollar to reset is more involved: it wraps the computation M m around a reset, and inserts a shift0 to remove the surrounding reset. The removal of reset is necessary for preservation of meaning (the return clause should not be evaluated under the original handler), and is realized by discarding the captured continuation z. Note that the translation of other constructs is defined homomorphically. For instance, we have λx. M m = λx. M m . 3

The macro translation is originally defined as: (λk. (λx. S0 z. k x) M m ) N m We adapted the translation by sequencing the application, and by incorporating the fact that N is always a λ-abstraction.

64

Y. Cong and K. Asai

Fig. 3. λ− h : a calculus of effect handlers without the return clause

4.2

Translating Between Handlers with and Without Return Clause

Guided by the translations between dollar and reset, we define macro translations between handlers with and without the return clause. We can easily imagine that adding the return clause is simple. To remove the return clause, we must somehow implement the removal of a handler. In a calculus of effect handlers, any non-local control is triggered by an operation call. This means we need to make an operation call when we wish to remove a handler. Unlike shift0, however, an operation call does not have an interpretation on its own. This means we need to implement the removing behavior in the surrounding handler, while distinguishing the “return operation” from regular operations. In Fig. 3, we define a calculus of effect handlers without the return clause, which we call λ− h . The calculus has labels l and pairs l, V , allowing us to represent the return and regular operations as do ret, V  and do op, V , respectively. The calculus also has pattern matching constructs, with which we can interpret the two kinds of operations differently in a handler. Note that these facilities are introduced only for the translation purpose. That is, we assume that the user can only program with unlabeled operations; they do not have access to the shaded constructs in Fig. 3. We now define the macro translations between λh and λ− h.

Understanding Algebraic Effect Handlers via Delimited Control Operators

65

From λh to λ− h do V m = do op, V m  handle M with {x. Mr ; x, k. Mh }m = handle (let y = M m in do ret, y) with {p, k → casep p of {l, x →

{casel l of {ret → Mr m ; op → Mh m }}}}

From user fragment of λ− h to λh do V m = do V m handle M with {x, k. Mh }m = handle M m with {x. return x; x, k. Mh m } The first translation4, 5 attaches a label op to regular operations and simulates the return clause by performing a ret operation, which removes the surrounding handler by discarding the continuation k. The second translation is fairly easy. 4.3

Correctness

The macro translations defined above preserve the meaning of programs. We state this property as the following theorem. Theorem 1 (Correctness of Macro Translations). Let = be the least congruence relation that includes the reduction  in λh and λ− h. 4

5

If we wish to apply these translations to a typed language, we would need to slightly modify the definition. The reason is that, the translation of regular handlers yields a single pattern variable x representing the arguments of the ret and op operations, which have different types in general. An anonymous reviewer suggests a different translation that does not use labels: handle M with {x. Mr ; x, k. Mh }m = (handle (let y = M m in return λ_. (λx. Mr m ) y) 



with {x, k . return λ_. Mh m [λz. k z 0/k]})

0 where M V is a shorthand for let x = M in x V and 0 can be replaced by any constant. The translation implements the return clause by creating thunks (to exit from a handler) and passing around a dummy argument (to trigger computation). This eliminates the need for labels, but in our view, it also makes the translation slightly harder to understand.

66

Y. Cong and K. Asai

1. If M = N in λh , then M m = N m in λ− h. (i.e., the fragment consisting of non2. If M = N in the user fragment of λ− h shaded constructs), then M m = N m in λh . Proof. By cases on the reduction relation M  N .

5

Deriving a CPS Translation

The classical way of specifying the semantics of control operators is to give a translation into continuation-passing style (CPS) [35], which converts control operators into plain lambda terms. In the case of shift0 and dollar (or reset), there exist several variants of CPS translation, differing in the representation of continuations [4,11,29,30,41]. Compared to control operators, the semantics of effect handlers seems to be less tightly tied to the CPS translation. In fact, the CPS translation of effect handlers has not been formally studied until recently [16,17]. This gives rise to a question: What would we obtain if we derive the CPS translation of effect handlers from that of control operators, which is considered definitional? In this section, we derive the CPS translation of effect handlers by composing the following translations. 1. The macro translation from effect handlers to shift0/dollar [13] 2. The CPS translation of shift0/dollar [30] We show that, by avoiding “administrative” constructs in the macro translation, and by being selective in the CPS translation, we obtain the same CPS translation as the unoptimized6 translation given by Hillerström et al. [17]. In what follows, we review the existing CPS translations of shift0/dollar and effect handlers (Sects. 5.1 and 5.2), as well as the macro translation from effect handlers to shift0/dollar (Sect. 5.3). We then compose the first and third translations to obtain the second translation (Sect. 5.4), thus formally relating the CPS translations of shift0/dollar and effect handlers. 5.1

CPS Translation of Shift0 and Dollar

In Fig. 4, we present the CPS translation from λS0 to the plain λ-calculus (with no value vs. computation distinction). The definition is adopted from Hillerström et al. [17] (for the λ-terms fragment) and Materzok and Biernacki [30] (for shift0/dollar). We have two mutually-defined translations _c and _c , taking care of values and computations, respectively. The former yields a direct-style value in the λ-calculus, whereas the latter yields a continuation-taking function. To go through the cases of the control operators, the translation turns a shift0 construct into a λ-abstraction, and a dollar construct into an application of the main computation M c to an ending continuation λx. N c 7 . 6 7

By “unoptimized translation”, we mean the first-order translation in Fig. 5 of Hillerström et al. [17]. The CPS translation of the original dollar operator N $ M is defined as λk. N c (λf. M c f k).

Understanding Algebraic Effect Handlers via Delimited Control Operators

67

Fig. 4. CPS translation of λS0 [17, 29]

Fig. 5. CPS translation of λh [17] (cases for λ-terms are the same as Fig. 4)

5.2

CPS Translation of Effect Handlers

In Fig. 5, we present the CPS translation from λh to the plain λ-calculus. The definition is adopted from Hillerström et al. [17]. We again have separate translations for values and computations. Among them, the computation translation yields a function that takes in two continuations: a pure continuation k for returning a value, and an effect continuation h for handling an operation. To go through the interesting cases, the translation of an operation calls the effect continuation h representing the interpretation of that operation8 . Notice that the resumption λx. k x h passed to h includes h itself, reflecting the deep nature of handlers. The translation of a handler sets the two continuations for the handled computation M c . Other cases are the same as the translation of λS0 . In particular, the translation of the return and let expressions requires only one continuation argument, as the effect continuation can be removed by η-reduction. 5.3

Macro Translation from λh to λS 0

Having seen the CPS translations, we look at the macro translation from λh to λS0 (Fig. 6), which is adapted from Forster et al. [13]. Roughly speaking, the translation converts operations into shift0 and handlers into dollar. Technically, it introduces an effect-continuation-passing mechanism to make the operation clause accessible to the shift0 operator. The result of the translation is somewhat verbose due to the presence of the return and let expressions; these are necessary for ensuring that the translation produces a well-formed expression in fine-grain call-by-value. The original translation of Forster et al. (Fig. 7) is simpler, as it is defined on call-by-push-value (where the operator of an application may be a computation). Note that, in both versions, the translation of λ-terms is defined homomorphically. 8

The original translation of Hillerström et al. [17] packages the two arguments to h into a tuple and associates the tuple with an operation label.

68

Y. Cong and K. Asai

Fig. 6. Macro translation from λh to λS0

Fig. 7. Simpler macro translation from λh to λS0 [13]

5.4

Composing Macro and CPS Translations

Now, let us derive a CPS translation of λh by composing the macro translation on λh and the CPS translation on λS0 . Operations. We begin with the case of operations. Below is the naïve composition of the macro and CPS translations. do V m c = S0 k. return (λh. let v1 = h V m in v1 (λx. let v2 = k x in v2 h)c = λk. λk1 . k1 (λh. λk2 . h V m c (λv1 . v1 (λx. λk3 . k x (λv2 . v2 h k3 )) k2 )) The result has four continuation variables: k, k1 , k2 , and k3 . Among them, the latter three continuations come from the return and let expressions introduced by the macro translation. As we mentioned before, these return and let are only necessary for the well-formedness of macro-translated expressions. In other words, they do not have any computational content. What this implies is that, when our ultimate goal is to convert effect handlers into plain λ-terms, we can use a simpler macro translation that does not yield these administrative return and let. This simpler translation is exactly the original translation of Forster et al. [13]. do V m c = S0 k. λh. h V m (λx. k x h)c The result of the simpler macro translation is not a proper expression in finegrain CBV: the shift0 operator has a value body, and the body has applications of a function to two values. This prevents us from directly applying the CPS translation in Fig. 4. However, we can still reuse the CPS translation, under the principle “do not introduce a continuation when it is not necessary”. In the case

Understanding Algebraic Effect Handlers via Delimited Control Operators

69

of the above expression, we simply apply the value translation to the body of shift0 and the arguments of the applications. Such a principle reminds us of selective CPS translations in the literature [2,31,39]. S0 k. λh. h V m (λx. k x h)c = λk. λh. h V m c (λx. k x h) The result of composition is identical to the existing translation of operations, which we saw in Fig. 5. Handlers. We next look at the handler case. Below is the naïve composition of the two translations. handle M with {x. Mr ; x, k. Mh }m c = let v = M m | x.return λh. Mr m  in v (λx. return λk. Mh m )c = λk1 . (M m c (λx. λk2 . k2 (λh. Mr m c ))) (λv. v (λx. λk3 . k3 (λk. Mh m c )) k1 ) As in the case of operations, the result has three continuation variables k1 , k2 , and k3 that originate from the macro translation. To avoid these continuation variables, we first apply the simpler macro translation, and then selectively apply the CPS traslation. This makes the composition more concise, as shown below. handle M with {x. Mr ; x, k. Mh }m c = M m | x.λh. Mr m  (λx. λk. Mh m )c = M m c (λx. λh. Mr m c ) (λx. λk. Mh m c ) The result is again identical to the existing translation of handlers, which we saw in Fig. 5.

6

Deriving a Type System from the CPS Translation

The traditional approach to designing a type system for control operators is to analyze their CPS translation [9], which exposes the typing constraints of each syntactic construct. In the case of shift0 and dollar, the CPS translation gives rise to a stack-like representation of effects, reflecting the ability of shift0 to capture metacontexts [29,30]. Unlike control operators, the type system of effect handlers has been designed independently of the CPS translation. Indeed, the first type system of effect handlers [3] was proposed before the first CPS translation [17]. This brings up a question: What would we obtain if we derive the type system of effect handlers from their CPS translation? In this section, we derive the type system of effect handlers following the recipe of Danvy and Filinski [9], which consists of two steps:

70

Y. Cong and K. Asai

1. Annotate the CPS image of each construct in the most general way 2. Encode the identified typing constraints into the typing rules We show that, when guided by the CPS translation, we obtain a type system with explicit answer types and a restricted form of answer-type modification [9]. In what follows, we review the existing type systems of shift0/dollar and effect handlers (Sects. 6.1 and 6.2), design a new type system of effect handlers by applying the CPS approach (Sect. 6.3), and prove the soundness of the type system (Sect. 6.4). 6.1

Type System of Shift0 and Dollar

In Fig. 8, we present the type system of λS0 . The type system is adopted from Hillerström et al [17] (for λ-terms) and Forster et al. [13] (for shift0 and dollar). There are two classes of types: value types (A, B) and computation types (C, D). The latter take the form A ! , which reads: the computation returns a value of type A while possibly performing an effect represented by 9 . An effect is a list of answer types, representing what kind of context is needed to evaluate a computation10 . The list is extended by (Shift0) (which requires a specific kind of context) and shrunk by (Dollar) (which supplies the required context). 6.2

Type System of Effect Handlers

In Fig. 9, we present the type system of λh . The type system is adopted from Hillerström et al. [17]. We again have value and computation types. An effect is either the pure effect ι or an operation type A ⇒ B; the latter tells us what kind of handler is needed to evaluate a computation. Operation types are introduced by (Do) (which requires a specific kind of operation clause) and eliminated by (Handle) (which supplies the required operation clause). The typing rules for λ-terms stay the same as those of λS0 , because they do not modify effects. 6.3

Applying the CPS Approach

The type system in Fig. 9 is defined on the direct-style expressions in λh . We now design a new type system based on the CPS translation. As we saw in Sect. 5, a CPS-translated λh computation is a function that receives a return continuation and an effect continuation. Among the two arguments, a return continuation takes in a value and an effect continuation, whereas an effect continuation takes in an operation argument and a resumption (a continuation whose handler is already given). Hence, if we annotate the CPS image of a computation in the most general way, we obtain something like this: 9 10

In the original type system of Forster et al. [13], effects are attached to the typing judgment instead of being part of a computation type. The effect representation does not allow answer-type modification. A more general (and involved) type system supporting answer-type modification is given by Materzok and Biernacki [30].

Understanding Algebraic Effect Handlers via Delimited Control Operators

71

Fig. 8. Type system of λS0

Fig. 9. Type system of λh (rules for λ-terms are the same as Fig. 8)

λk A→(A1 →(B1 →C1 )→D1 )→E1 . λhA2 →(B2 →C2 )→D2 . eE2 These annotations are however too general. The semantics of deep handlers naturally gives rise to the following invariants. 1. The types A1 → (B1 → C1 ) → D1 and A2 → (B2 → C2 ) → D2 of the two effect continuations are the same. This is because a computation and its continuation are evaluated under the same handler (when handlers are deep).

72

Y. Cong and K. Asai

2. The return type E1 of the return continuation and the return type C2 of the resumption are the same. This is because a resumption is constructed using a return continuation. 3. The type E2 of the eventual answer must be either C2 or D2 . This is because the answer is produced by the return or the operation clause of the surrounding handler. These invariants lead to the following refined annotations. 

λk A→(A →(B



→C)→D)→C



. λhA →(B



→C)→D

. eE

where E = C or D

The annotations have six different types. Among them, A , B  , C, D, and E specify the kind of the handler required by the computation, as well as the kind of answer to be produced by the handler. By including these types in non-empty effects, we arrive at the following effect representation.  ::= ι | A ⇒ B, C, D, E

where E = C or D

Here, A ⇒ B, C, D, E carries the following information. – The computation may perform an operation of type A ⇒ B. – When evaluated under a handler whose return clause has type C and whose operation clause has type D, the computation returns an answer of type E (which must be either C or D). Using this representation of effects, we design the typing rules for λh computations and values. We do this by annotating the CPS image of individual syntactic constructs and converting annotated expressions into typing derivations. Return Expressions. We begin by annotating the CPS image of a return expression. To make the calculation of types easier to follow, we explicitly write the effect continuation h. 

λk A→(A →(B



→C)→D)→C



. λhA →(B



→C)→D

C . (k V A c h)

The trivial use of the continuation forces the type of the eventual answer to be C (corresponding to invariant 3 mentioned above). From this annotated expression, we obtain the following typing rule. Γ V :A (Return) Γ  return V : A ! A ⇒ B  , C, D, C

Understanding Algebraic Effect Handlers via Delimited Control Operators

73

Application. We next annotate the CPS image of an application. C (V A→C W A c c )

The annotations simply tell us that the effect of an application comes from the body of the function. This corresponds to the following typing rule. Γ V :A→C Γ W :A (App) Γ V W :C

Let Expressions. Having analyzed application, we consider the let expression. 



. λk B→(A →(B →C)→D)→C (A→(A →(B  →C)→D)→C)→(A →(B  →C)→D)→E M c (B→(A →(B  →C)→D)→C)→(A →(B  →C)→D)→C

(λxA . N c

k)

The sequencing of M and N forces the eventual answer type of N to be C. The application of M c then forces the eventual answer type of the entire construct to be E. The corresponding typing rule is as follows. Γ  M : A ! A ⇒ B  , C, D, E Γ, x : A  N : B ! A ⇒ B  , C, D, C (Let) Γ  let x = M in N : B ! A ⇒ B  , C, D, E

Operations. We now move on to the case of an operation call. λk B



→(A →(B  →C)→D)→C



. λhA →(B



→C)→D

. (h V c (λxA . k x h))D

The application of h forces the eventual answer type to be D (invariant 3). The application k x h restricts the annotations in two ways: (i) the handler type of the whole expression and that of k must coincide (invariant 1); and (2) the return type of k and that of the resumption must coincide (invariant 2). Below is the derived typing rule. Γ  V : A (Do) Γ  do V : B  ! A ⇒ B  , C, D, D

74

Y. Cong and K. Asai

Handlers. Lastly, we consider the case of a handler. (A→(A →(B  →C)→D)→C)→(A →(B  →C)→D)→E

M c   (λxA . λhA →(B →C)→D . er C c )   E (λxA . λk B →C . eh D )) c

The construction requires the effect of the handled expression M and the type of the two clauses of the handler to be consistent. Thus we obtain the following typing rule. Γ  M : A ! A ⇒ B  , C, D, E Γ, x : A  Mr : C Γ, x : A , k : B  → C  Mh : D (Handle) Γ  handle M with {x. Mr ; x, k. Mh } : E

Values. Let us complete the development by giving the typing rules for variables and abstractions. These are derived straightforwardly from the CPS translation on values. The only thing that is non-standard is the well-formedness condition  A in the (Var) rule. Here, well-formedness means every function type that appears in a type takes the form A → B ! A ⇒ B  , C, D, C or A → B ! A ⇒ B  , C, D, D. x:A∈Γ A (Var) Γ x:A

Γ, x : A  M : C (Abs) Γ  λx. M : A → C

Through this exercise, we obtained a type system that is different from the one presented in Fig. 9. First, the type system explicitly keeps track of answer types. Second, the type system allows a form of answer-type modification [1,9, 22]. Answer-type modification is the ability of an effectful computation to change the return type of its delimited context. In our setting, this ability is gained by allowing the return and operation clauses of a handler to have different types. The answer-type modification supported in this type system is however very limited. Specifically, it does not allow answer-type modification in a captured continuation. In fact, we have already seen where this restriction comes from: the let expression. Recall that, in the typing rule (Let), the body N of let (which corresponds to the continuation of M ) has a type of the form B ! A ⇒ B  , C, D, C. This means N must be a pure computation (which is eventually handled by the return clause of type C) or it must be handled by a handler whose return and operation clauses have the same type (i.e., C = D). 6.4

Soundness

The type system we derived from the CPS translation is sound. We state this property as the following theorem.

Understanding Algebraic Effect Handlers via Delimited Control Operators

75

Theorem 2 (Soundness of Type System). If Γ  M : A ! ι, then M  return V , and Γ  V : A. Proof. The statement is witnessed by a CPS interpreter11 written in the Agda programming language [33]. The interpreter takes in a well-typed λh computation and returns a fully-evaluated Agda value. The signature of the interpreter corresponds exactly to the statement of soundness, and the well-typedness of the interpreter implies the validity of the statement.

7

Related Work

Variations of Effect Handler Calculi. There is a discussion of the relationship between effect operations with and without a label, which is similar to our discussion of handlers with and without the return clause. The handling of a labeled operation may involve skipping of handlers that do not take care of the operation in question. To simulate labeled operations in a calculus with unlabeled operations, Biernacki et al. [5] introduce an operator [_] called “lift”, which allows an operation to skip the innermost handler and be handled by an outer handler. This enables programming with multiple effects without using labels, although it requires the programmer to know what handlers are surrounding each operation in what order. CPS Translations of Effect Handlers. The CPS translations of effect handlers have been developed as a technique for compiling effect handlers into common runtime platforms. For this reason, existing CPS translations are either directed by the specific typing discipline of the source language [7,24] or tuned for the particular implementation strategy of the compiler [16,17]. We derived a CPS translation of effect handlers from the definitional CPS translation of shift0 and dollar. As a result, we obtained a translation that is identical to Hillerström’s [17] unoptimized translation, which we regard as the definitional translation of general (deep) effect handlers. Type Systems of Effect Handlers. There are different flavors of type systems for effect handlers. In the most traditional type systems [3,19], effects are represented as a set of operations, similar to the effect systems for side-effect analysis [32]. In some research languages such as Koka [23], Frank [27], and Links [14], effects are treated as row types, which were originally developed for type inference with records [38]. More recently, several languages [8,43] adopt the notion of capabilities from object-oriented programming as an approach to safe handling of effects. Unlike the type system we developed in Sect. 6, these type systems are all defined independent of the CPS translation, and they do not explicitly carry answer types.

11

The interpreter is available at https://github.com/YouyouCong/tfp22.

76

Y. Cong and K. Asai

Effect Handlers and Control Operators. The relationship between effect handlers and control operators has been studied from several different perspectives. Forster et al. [13] and Piróg et al. [34] establish macro translations to compare the expressiveness of the two facilities. Kiselyov and Sivaramakrishnan [21] define a similar translation to embed the Eff language into OCaml. We exploit the results of these studies to enhance our understanding of effect handlers.

8

Conclusion and Future Perspectives

In this paper, we presented three results from our study on understanding deep effect handlers via the shift0 and dollar operators. Specifically, we defined a pair of macro translations that add and remove the return clause, derived a CPS translation for effect handlers from the CPS translation for shift0/dollar, and designed a type system for effect handlers from the CPS translation. As a continuation of this work, we intend to visit those challenges that have been solved for shift0 and dollar (or reset) but not for effect handlers. One question we would like to investigate is whether effect handlers can fully express the CPS hierarchy [10]. The other question we are interested in is how to give an equational axiomatization [28] and a reflection [6] of effect handlers. By answering these questions, we would be able to discover novel tecnhiques for implementing, optimizing, and reasoning about effect handlers. As an extension of our approach, we plan to study shallow effect handlers [15] by leveraging their connection to the control0 and prompt0 operators [34]. These operators have a more complex semantics due to the absence of the delimiter surrounding captured continuations, but we conjecture that it is possible to establish similar results to what we have presented in this paper. Acknowledgments. We gratefully thank the anonymous reviewers for their constructive feedback on the presentation and technical development. This work was supported by JSPS KAKENHI under Grant No. JP18H03218, No. JP19K24339, and No. JP22H03563.

References 1. Asai, K.: On typing delimited continuations: three new solutions to the printf problem. Higher-Order Symb. Comput. 22(3), 275–291 (2009). https://doi.org/ 10.1007/s10990-009-9049-5 2. Asai, K., Uehara, C.: Selective CPS transformation for shift and reset. In: Proceedings of the ACM SIGPLAN Workshop on Partial Evaluation and Program Manipulation, pp. 40–52. PEPM 2018, ACM, New York (2017). https://doi.org/ 10.1145/3162069 3. Bauer, A., Pretnar, M.: An effect system for algebraic effects and handlers. In: Heckel, R., Milius, S. (eds.) CALCO 2013. LNCS, vol. 8089, pp. 1–16. Springer, Heidelberg (2013). https://doi.org/10.1007/978-3-642-40206-7_1 4. Biernacki, D., Danvy, O., Millikin, K.: A dynamic continuation-passing style for dynamic delimited continuations. ACM Trans. Program. Lang. Syst. 38(1), 1–25 (2015). https://doi.org/10.1145/2794078

Understanding Algebraic Effect Handlers via Delimited Control Operators

77

5. Biernacki, D., Piróg, M., Polesiuk, P., Sieczkowski, F.: Handle with care: relational interpretation of algebraic effects and handlers. Proc. ACM Program. Lang. 2(POPL), 1–30 (2017). https://doi.org/10.1145/3158096 6. Biernacki, D., Pyzik, M., Sieczkowski, F.: Reflecting stacked continuations in a finegrained direct-style reduction theory. In: PPDP 2021, Association for Computing Machinery, pp. 1–13. New York (2021). https://doi.org/10.1145/3479394.3479399 7. Brachthäuser, J.I., Schuster, P., Ostermann, K.: Effect handlers for the masses. Proc. ACM Program. Lang. 2(OOPSLA), 1–27 (2018) 8. Brachthäuser, J.I., Schuster, P., Ostermann, K.: Effects as capabilities: effect handlers and lightweight effect polymorphism. Proc. ACM Program. Lang. 4(OOPSLA), 1–30 (2020). https://doi.org/10.1145/3428194 9. Danvy, O., Filinski, A.: A functional abstraction of typed contexts. BRICS 89/12 (1989) 10. Danvy, O., Filinski, A.: Abstracting control. In: Proceedings of the 1990 ACM Conference on LISP and Functional Programming, pp. 151–160. ACM (1990) 11. Dyvbig, R.K., Peyton Jones, S., Sabry, A.: A monadic framework for delimited continuations. J. Funct. Program. 17(6), 687–730 (2007). https://doi.org/10.1017/ S0956796807006259 12. Felleisen, M.: On the expressive power of programming languages. In: Selected Papers from the Symposium on 3rd European Symposium on Programming, pp. 35–75. ESOP 1990, Elsevier North-Holland Inc, New York (1991) 13. Forster, Y., Kammar, O., Lindley, S., Pretnar, M.: On the expressive power of user-defined effects: effect handlers, monadic reflection, delimited control. J. Funct. Program. 29, e15 (2019). https://doi.org/10.1017/S0956796819000121 14. Hillerström, D., Lindley, S.: Liberating effects with rows and handlers. In: Proceedings of the 1st International Workshop on Type-Driven Development, pp. 15–27. TyDe 2016, ACM, New York (2016). https://doi.org/10.1145/2976022.2976033 15. Hillerström, D., Lindley, S.: Shallow effect handlers. In: Ryu, S. (ed.) APLAS 2018. LNCS, vol. 11275, pp. 415–435. Springer, Cham (2018). https://doi.org/10.1007/ 978-3-030-02768-1_22 16. Hillerström, D., Lindley, S., Atkey, R.: Effect handlers via generalised continuations. J. Funct. Program. 30, e5 (2020). https://doi.org/10.1017/ S0956796820000040 17. Hillerström, D., Lindley, S., Atkey, R., Sivaramakrishnan, K.: Continuation passing style for effect handlers. In: Proceedings of 2nd International Conference on Formal Structures for Computation and Deduction, pp. 18:1–18:19. FSCD 2017, Schloss Dagstuhl-Leibniz-Zentrum fuer Informatik (2017) 18. Kameyama, Y., Yonezawa, T.: Typed dynamic control operators for delimited continuations. In: Garrigue, J., Hermenegildo, M.V. (eds.) FLOPS 2008. LNCS, vol. 4989, pp. 239–254. Springer, Heidelberg (2008). https://doi.org/10.1007/9783-540-78969-7_18 19. Kammar, O., Lindley, S., Oury, N.: Handlers in action. In: Proceedings of the 18th ACM SIGPLAN International Conference on Functional Programming, pp. 145–158. ICFP 2013, ACM, New York (2013). https://doi.org/10.1145/2500365. 2500590 20. Kiselyov, O., Shan, C.: A substructural type system for delimited continuations. In: Della Rocca, S.R. (ed.) TLCA 2007. LNCS, vol. 4583, pp. 223–239. Springer, Heidelberg (2007). https://doi.org/10.1007/978-3-540-73228-0_17 21. Kiselyov, O., Sivaramakrishnan, K.C.: Eff directly in OCaml. In: Asai, K., Shinwell, M. (eds.) Proceedings of ML/OCaml 2016, pp. 23–58 (2018). https://doi.org/10. 4204/EPTCS.285.2

78

Y. Cong and K. Asai

22. Kobori, I., Kameyama, Y., Kiselyov, O.: Answer-type modification without tears: prompt-passing style translation for typed delimited-control operators. In: Electronic Proceedings in Theoretical Computer Science EPTCS 212 (Post-Proceedings of the Workshop on Continuations 2015), pp. 36–52 (2016). https://doi.org/10. 4204/EPTCS.212.3 23. Leijen, D.: Koka: programming with row polymorphic effect types. In: 5th Workshop on Mathematically Structured Functional Programming. MSFP 2014 (2014). https://doi.org/10.4204/EPTCS.153.8 24. Leijen, D.: Type directed compilation of row-typed algebraic effects. In: Proceedings of the 44th ACM SIGPLAN Symposium on Principles of Programming Languages, pp. 486–499. POPL 2017, ACM, New York (2017). https://doi.org/10. 1145/3009837.3009872 25. Levy, P.B.: Call-By-Push-Value: A Functional/Imperative Synthesis. Springer, Dordrecht (2003). https://doi.org/10.1007/978-94-007-0954-6 26. Levy, P.B., Power, J., Thielecke, H.: Modelling environments in call-by-value programming languages. Inf. Comput. 185(2), 182–210 (2003) 27. Lindley, S., McBride, C., McLaughlin, C.: Do be do be do. In: Proceedings of the 44th ACM SIGPLAN Symposium on Principles of Programming Languages, pp. 500–514. POPL 2017, ACM, New York (2017). https://doi.org/10.1145/3009837. 3009897 28. Materzok, M.: Axiomatizing subtyped delimited continuations. In: Computer Science Logic 2013. CSL 2013, Schloss Dagstuhl-Leibniz-Zentrum fuer Informatik (2013) 29. Materzok, M., Biernacki, D.: Subtyping delimited continuations. In: Proceedings of the 16th ACM SIGPLAN International Conference on Functional Programming, pp. 81–93. ICFP 2011, ACM, New York (2011). https://doi.org/10.1145/2034773. 2034786 30. Materzok, M., Biernacki, D.: A dynamic interpretation of the CPS hierarchy. In: Jhala, R., Igarashi, A. (eds.) APLAS 2012. LNCS, vol. 7705, pp. 296–311. Springer, Heidelberg (2012). https://doi.org/10.1007/978-3-642-35182-2_21 31. Nielsen, L.R.: A selective CPS transformation. Electron. Notes Theor. Comput. Sci. 45, 311–331 (2001) 32. Nielson, F., Nielson, H.R., Hankin, C.: Type and effect systems. In: Principles of Program Analysis, pp. 283–363. Springer, Heidelberg (1999). https://doi.org/10. 1007/978-3-662-03811-6_5 33. Norell, U.: Towards a practical programming language based on dependent type theory. Ph.D. thesis, Chalmers University of Technology (2007) 34. Piróg, M., Polesiuk, P., Sieczkowski, F.: Typed equivalence of effect handlers and delimited control. In: 4th International Conference on Formal Structures for Computation and Deduction (FSCD 2019). Schloss Dagstuhl-Leibniz-Zentrum fuer Informatik (2019) 35. Plotkin, G.: Call-by-name, call-by-value and the λ-calculus. Theoret. Comput. Sci. 1(2), 125–159 (1975) 36. Plotkin, G., Power, J.: Algebraic operations and generic effects. Appl. Categ. Struct. 11(1), 69–94 (2003). https://doi.org/10.1023/A:1023064908962 37. Plotkin, G., Pretnar, M.: Handlers of algebraic effects. In: Castagna, G. (ed.) ESOP 2009. LNCS, vol. 5502, pp. 80–94. Springer, Heidelberg (2009). https://doi.org/10. 1007/978-3-642-00590-9_7 38. Rémy, D.: Type Inference for Records in Natural Extension of ML, pp. 67–95. MIT Press, Cambridge (1994)

Understanding Algebraic Effect Handlers via Delimited Control Operators

79

39. Rompf, T., Maier, I., Odersky, M.: Implementing first-class polymorphic delimited continuations by a type-directed selective CPS-transform. In: Proceedings of the 14th ACM SIGPLAN International Conference on Functional Programming, pp. 317–328. ICFP 2009, ACM, New York (2009). https://doi.org/10.1145/1596550. 1596596 40. Schuster, P., Brachthäuser, J.I., Ostermann, K.: Compiling effect handlers in capability-passing style. Proc. ACM Program. Lang. 4(ICFP), 1–28 (2020). https://doi.org/10.1145/3408975 41. Shan, C.C.: A static simulation of dynamic delimited control. Higher-Order Symb. Comput. 20(4), 371–401 (2007) 42. Xie, N., Brachthäuser, J.I., Hillerström, D., Schuster, P., Leijen, D.: Effect handlers, evidently. Proc. ACM Program. Lang. 4(ICFP), 1–29 (2020). https://doi.org/10. 1145/3408981 43. Zhang, Y., Myers, A.C.: Abstraction-safe effect handlers via tunneling. Proc. ACM Program. Lang. 3(POPL), 1–29 (2019). https://doi.org/10.1145/3290318 44. Zhang, Y., Salvaneschi, G., Myers, A.C.: Handling bidirectional control flow. Proc. ACM Program. Lang. 4(OOPSLA), 1–30 (2020). https://doi.org/10.1145/3428207

Reducing the Power Consumption of IoT with Task-Oriented Programming Sjoerd Crooijmans, Mart Lubbers(B) , and Pieter Koopman Institute for Computing and Information Sciences, Radboud University Nijmegen, Nijmegen, The Netherlands [email protected], {mart,pieter}@cs.ru.nl Abstract. Limiting the energy consumption of IoT nodes is a hot topic in green computing. For battery-powered devices this necessity is obvious, but the enormous growth of the number of IoT nodes makes energy efficiency important for every node in the IoT. In this paper, we show how we can automatically compute execution intervals for our task-oriented programs for the IoT. These intervals offer the possibility to save energy by bringing the microprocessor driving the IoT node into a low-power sleep mode until the task need to be executed. Furthermore, they offer an elegant way to add interrupts to the system. We do allow an arbitrary number of tasks on the IoT nodes and achieve significant reductions of the energy consumption by bringing the microprocessor in sleep mode as much as possible. We have seen energy reductions of an order of magnitude without imposing any constraints on the tasks to be executed on the IoT nodes. Keywords: Sustainable IoT · Green computing programming · Edge computing

1

· Task-oriented

Introduction

The Internet of Things (IoT) is omnipresent and powered by software. Depending on whom you ask, the estimated number of connected IoT devices reached between 25 and 100 billion in 2021. IoT systems are traditionally designed according to multi-layered or tiered architectures. As a consequence, discrete programs written in distinct languages with different abstraction levels power the individual layers, forming a heterogeneous system. The variation in components makes programming IoT systems complicated, error-prone and expensive. The edge layer of IoT contains the small devices that sense and interact with the world and it is crucial to lower their energy consumption. While individual devices consume little energy, the sheer number of devices in total amounts to a lot. Furthermore, many IoT devices operate on batteries and higher energy consumption increases the amount of e-waste as IoT devices are often hard to reach and consequently hard to replace [13]. To reduce the power consumption of an IoT device, the specialized lowpower sleep modes of the microprocessors can be leveraged. Different sleep modes c Springer Nature Switzerland AG 2022  W. Swierstra and N. Wu (Eds.): TFP 2022, LNCS 13401, pp. 80–99, 2022. https://doi.org/10.1007/978-3-031-21314-4_5

Reducing the Power Consumption of IoT with Task-Oriented Programming

81

achieve different power reductions because of their different run time characteristics. These specifics range from disabling or suspending Wi-Fi; stopping powering (parts) of the RAM; disabling peripherals; or even turning off the processor completely, requiring an external signal to wake up again. Determining when exactly and for how long it is possible to sleep is expensive in the general case and often requires annotations in the source code, a real-time operating system or a handcrafted scheduler. Task-oriented programming (TOP) is a novel declarative programming paradigm with the potential to solve many of the aforementioned problems. In this paradigm, tasks are the basic building blocks and they can be combined using combinators to describe workflows [15]. This declarative specification of the program only describes the what and not the how of execution. The system executing the tasks takes care of the gritty details such as the user interface, data storage and communication [16]. An example of a TOP language is the iTask system, a general-purpose framework for specifying multi-user distributed web applications for workflows [14]. iTask is implemented as an embedded domainspecific language (DSL) in the functional programming language Clean [4,6]. mTask lies on the other side of the spectrum and aims to solve semantic friction in IoT. It is a domain-specific TOP language and system specifically designed for IoT devices, implemented as an embedded DSL in iTask. Where iTask abstracts away from details such as user interfaces, data storage, and persistent workflows, mTask offers abstractions for edge layer-specific details such as the heterogeneity of architectures, platforms and frameworks; peripheral access; and multitasking. Yet, it lacks abstractions for energy consumption and scheduling. In mTask, tasks are implemented as a rewrite system, where the work is automatically segmented in small atomic bits and stored as a task tree. Each cycle, a single rewrite step is performed on all task trees, during rewriting, tasks do a bit of their work and progress steadily, allowing interleaved and seemingly parallel operation. After a loop, the run-time system (RTS) knows which task is waiting on which triggers and is thus able to determine the next execution time for each task automatically. Utilising this information, the RTS can determine when it is possible and safe to sleep and choose the optimal sleep mode according to the sleeping time. For example, the RTS never attempts to sleep during an I2 C communication because I/O is always contained within a rewrite step. 1.1

Research Contribution

This paper shows that with minor changes to the mTask language from the perspective of the TOP programmer, the energy consumption of the program’s execution can be significantly reduced. We show that with an intensional analysis of the task trees at run time, the mTask scheduler can automatically determine the optimal sleep time and sleep mode. Not all tasks have a default rewrite rate that works in all situations, so variants of tasks are added in which the programmer can fine-tune the polling rate. Furthermore, we add an interface to (hardware) interrupts to the mTask language, allowing the program to be notified in case of an external event, resulting in more reactive programs.

82

2

S. Crooijmans et al.

Task-Oriented Programming

TOP is a high-level declarative programming paradigm to specify distributed interactive multi-user systems [14,15]. Developers describe in TOP the work to be done by the systems or users in the form of abstract tasks. Implementation details, like the encoding of data during communication, are handled by the system rather than by the TOP programmer. Tasks describe a unit of work ranging from reading a single sensor to an entire IoT system. Tasks are rewrite systems that produce a result after each step. Possible task results are: No value if the task has no observable value for other tasks. For example, a web-editor that is empty or incomplete is a task with a NoValue result. Unstable if the task has an intermediate observable value. This value is by construction properly typed and can change in the future. Examples are a properly filled out web-editor or a reading a sensor. Stable if the task has a final observable value. This value is by construction properly typed and fixed. Examples are a properly filled out web-editor after pressing the Continue button or a task that determines that a temperature sensor has passed a given threshold. Basic tasks are the primitive building blocks of a TOP program. Typical examples are web-editors, reading sensors, waiting some time and controlling peripherals. Tasks can be composed into bigger tasks by combinators. There are combinators for the parallel and sequential composition of tasks. Apart from task results, tasks can also communicate via shared data sources (SDSs). There are basic tasks to read, write and update such a typed SDS. 2.1

mTask

TOP is also very suited to program nodes in the IoT. However, typical nodes in the IoT are cheap and small microprocessors with a very limited amount of processing power and memory. Such a system cannot run a web server nor a browser as a client to execute an iTask program. This is also not necessary, typical IoT nodes just control a few sensors and actuators. Rather than sending all sensor readings to some server and the actuator control back from the server to the IoT node, we want to do a significant part of the computing on the IoT nodes. This concept is popular under the name edge computing. To enable TOP on small microprocessors we have created mTask [10,12]. The domain-specific language mTask is also embedded in Clean. In contrast to iTask, it is not a shallowly embedded DSL but it is a tagless-final style DSL [5]. The target language of iTask is equal to the host language Clean, this is called a homogeneous DSL [17]. The target code of mTask is byte code which is shipped to a microprocessor in the IoT and interpreted by the mTask runtime system running there. Hence, mTask is a heterogeneous embedded DSL. The big advantage of this approach is that we can carefully control which parts of the application is mTask code and needs to be shipped to the IoT node. This prevents that

Reducing the Power Consumption of IoT with Task-Oriented Programming

83

all Clean language machinery ends up in such a small system. Moreover, we can dynamically ship tasks to the IoT nodes without the need to reprogram the flash memory of those systems. Reprogramming this memory is slow and can be done only a few thousand times. The archetypal example of programs running on microprocessors is the blink example that blinks the single bit display, just a LED, of such systems. In Listing 1 we give a slightly more complex example in mTask that blinks two LEDs at their own slowly changing speed. blinkTask :: Main (MTask v Bool) | mtask v blinkTask = declarePin BuiltinLEDPin PMOutput λled1  declarePin D0 PMOutput λled2  fun λblink = (λ(led, interval, state)  delay interval >>|. writeD led state >>|. blink (led, interval +. lit 1, Not state) In {main = blink (led1, lit 496, true) .||. blink (led2, lit 8128, true)} Listing 1. An mTask task to blink two LEDs at their own rate.

The Clean function blinkTask contains the mTask code for the blink task. It starts by declaring two led objects to represent the output general-purpose input/output pin to control the LED as output. Next, it declares an mTask function called blink. This function has the led, delay time and new state of the LED as arguments. The task in this function is composed of three subtasks. First, it waits time millisecond by delay. Second, it writes the given state to the declared output led. Finally, it calls itself recursively with the inverted state as the argument. In the main expression two of these blink tasks are composed in parallel with the .||. combinator. The lit in the arguments of the subtasks lifts the given constant from the host language to the embedded DSL. There are some important differences to the usual C-programs controlling microprocessors. First, tail-recursive functions are perfectly safe in mTask. Next, the delay task is not blocking the entire program, but just producing a NoValue result until the given time has passed.

3

Energy Efficient IoT Nodes

There are various estimates about the number of IoT nodes. What the estimates have in common is that the number is relatively large and rapidly increasing. Figure 1 contains a popular estimate of the worldwide number of nodes1 . Currently, there are worldwide just above 40 billion connected IoT devices. There is a wide variety of IoT nodes. Relative simple single board computers, like a Raspberry Pi 4, are often used. These are full-fledged computers running Linux. This operating system enables multithreading and the dynamic uploading of new applications via remote access. Such a single board computer costs about A C70 and consumes 4–6W. 1

https://statinvestor.com/data/33967/iot-number-of-connected-devices-worldwide/.

84

S. Crooijmans et al.

Fig. 1. Number of connected IoT nodes worldwide.

For battery powered nodes, the energy consumption of such a single board computer is obviously inconveniently high. Considering the number of IoT nodes, the total energy consumption for all nodes would also be rather high for single board computers connected to a power outlet. The total annual energy consumption of all IoT nodes would be 1.7 PWh/year if they consume 5W each and are always available. This is about 6% of the annual electrical energy production of 26.8 PWh/year2 , about 500 times the production of the only Dutch nuclear power plant3 , and 23 of the annual production of renewable electricity4 . Fortunately, there are microprocessor based systems that require significantly less energy, typically 1–500 mW, than single board computers and are very cheap as well. The price to be paid is that microprocessors are much slower and have a very limited amount of memory. As a consequence of these bordered capacities, a microprocessor typically has no operating system or at most special purpose real-time operating system like FreeRTOS. Despite their limited capabilities, microprocessors are often more powerful than needed for IoT tasks. This implies that we can switch them off, or partially off, for some fraction of the time. Microprocessors have special low power sleep modes to enable energy saving. The sleep modes as well as the associated energy consumption are model specific. Table 1 lists some examples for two popular microprocessor boards. The Wemos C6. The Adafruit Feather D1 mini5 is powered by an ESP8266 and costs about A

2 3 4 5

https://www.bp.com/en/global/corporate/energy-economics/statistical-review-ofworld-energy/electricity.html. https://nl.wikipedia.org/wiki/Kerncentrale Borssele. https://www.bp.com/content/dam/bp/business-sites/en/global/corporate/pdfs/ energy-economics/statistical-review/bp-stats-review-2021-electricity.pdf. https://www.wemos.cc/en/latest/d1/d1 mini.html.

Reducing the Power Consumption of IoT with Task-Oriented Programming

85

Table 1. Current use in mA of two microprocessor boards in various sleep modes. Component Wemos D1 mini

Adafruit Feather M0 Wifi

Active

Modem Light sleep sleep

Deep sleep

Active

Modem sleep

Light sleep

Deep sleep

Wi-Fi

On

Off

Off

Off

On

Off

Off

Off

CPU

On

On

Pending Off

On

On

Idle

Idle

RAM

On

On

On

Off

On

On

On

Low power

Current

100–240 15

0.5

0.002

90–300 5

2

0.005

M0 WiFi6 is powered by an ATSAMD21 and an ATWINC1500 for Wi-Fi, it costs about A C50. This table shows that switching the Wi-Fi radio off yields the biggest energy savings. In most IoT applications, we need Wi-Fi for communications. It is fine to switch it off, but after switching it on, the Wi-Fi protocol needs to transmit a number of messages to re-establish the connection. This implies that it is only worthwhile to switch the radio off when this can be done for some time. The details vary per system and situation. As a rule of thumb, it is only worthwhile to switch the Wi-Fi off when it is not needed for at least some tens of seconds. The data in Table 1 shows that it is worthwhile to put the system in some sleep mode when there is temporarily no work to be done. A deeper sleep mode saves more energy, but also requires more work to restore the software to its working state. A processor like the ESP8266 driving the Wemos D1 mini loses the content of its RAM in deep sleep mode. This implies that the program itself is preserved, since it is stored in flash memory, but the entire program state is lost. When there is a program state to be preserved, we must either store it elsewhere, limit us to light sleep, or use a microprocessor that keeps the RAM intact during deep sleep. For IoT nodes executing a single task, explicit sleeping to save energy can be achieved without too much hassle. This becomes much more challenging as soon as multiple independent tasks run on the same node. Sleeping of the entire node induced by one task prevents progress of all tasks. This is especially annoying when the other tasks are executing time critical parts, like communication protocols. Such protocols control the communication with sensors and actuators. Without the help of an OS, the programmer is forced to combine all subtasks into one big system that decides if it is safe to sleep for all subtasks.

4

Scheduling Tasks Efficiently

The mTask system enables an elegant solution for these dynamic scheduling and sleeping problems. To understand the solution, we have to reveal some details about the mTask implementation. This system consists of two components. First, 6

https://www.adafruit.com/product/3010.

86

S. Crooijmans et al.

there is the embedded DSL as introduced in Sect. 2.1. Next, there is an execution environment for those programs running on IoT nodes. This execution environment is able to execute multiple mTask programs simultaneously, it is a featherlight domain-specific operating system. Every mTask program is embedded in an iTask program, a TOP DSL very similar to mTask. In contrast to mTask, iTask is geared to client server webapplications. The iTask and mTask components form a single source that is statically type-checked by the compiler of the host language Clean. The mTask components determine what is done by the IoT nodes. The surrounding iTask program determines the IoT node to be used and the moment it is shipped. The communication between iTask and mTask programs is outside the scope of this paper, see [11] for details. A mTask program is dynamically transformed to byte code. This byte code and the initial mTask expression are shipped to mTask IoT node. For the example in Listing 1 there is byte code representing the blink function and main determines the initial expression. The mTask rewrite engine rewrites the current expression just a single rewrite step at a time. When subtasks are composed in parallel, like blink tasks in Listing 1, all subtasks are rewritten unless the result of the first rewrite step makes the result of the other tasks superfluous. This yields for our example: {main = delay 496 >>|. writeD led1 true >>|. blink (led1, 497, false) .||. delay 8128 >>|. writeD led2 true >>|. blink (led2, 8129, false)} Listing 2. State of the task from Listing 1 after a single rewrite step.

The mTask node can inspect this expression. It is obvious that nothing will change the next 496 ms. The system can therefore go to sleep for this period when there are no other tasks7 . The task design ensures such that all time critical communication with peripherals is within a single rewrite step. This is very convenient, since the system can inspect the current state of all mTask expressions after a rewrite and decide if sleeping and how long is possible. As a consequence, we cannot have fair multitasking. When a single rewrite step would take forever due to an infinite sequence of function calls, this would block the entire IoT node. Infinite sequences rewrite steps, as in the blink example above, are perfectly fine. The mTask system does proper tail-call optimizations to facilitate this. 4.1

Evaluation Interval

Some mTask examples contain one or more explicit delay primitives, offering a natural place for the node executing it to pause. However, there are many mTask programs that just specify a repeated set of primitives. A typical example is the program that reads the temperature for a sensor and sets the system LED if the reading is below some given goal. 7

In the implementation a delay d is replaced by a waitUntil (now + d) that compares the clock time of the system with the given start time.

Reducing the Power Consumption of IoT with Task-Oriented Programming

87

thermostat :: Main (MTask v Bool) | mtask v thermostat = DHT I2Caddr λdht. {main = rpeat ( temperature dht >>∼. λtemp. writeD builtInLED (goal > |. t2 ) = R(t1 )

(5) (6)

R(t >>= . f ) = R(t) R(t >>∗. [a1 . . . an ]) = R(t) R(rpeat t) = 0, 0 R(rpeatEvery d t) = 0, 0 R(delay d) = d, d  ∞, ∞ if t is Stable R(t) = rl , ru  otherwise

(7) (8) (9) (10) (11) (12)

We will briefly discuss the various cases of deriving refresh rates together with the task semantics of the different combinators. Parallel Combinators. For the parallel composition of tasks we compute the intersection of the refresh intervals of the components as outlined in the definition of ∩safe . The operator .||. in Eq. 4 is the or -combinator; the first subtask that produces a stable value determines the result of the composition. The operator .&&. in Eq. 5 is the and -operator. The result is the tuple containing both results when both subtasks have a stable value.

Reducing the Power Consumption of IoT with Task-Oriented Programming

89

Sequential Combinators. For the sequential composition of tasks we only have to look at the refresh rate of the current task on the left. The sequential composition operator >>|. in Eq. 6 is similar to the monadic sequence operator >>|. The operator >>=. in Eq. 7 provides the stable task result to the function on the right-hand side, similar to the monadic bind. The operator >>∼. steps on an unstable value and is otherwise equal to >>=.. The step combinator >>*. in Eq. 8 has a list of conditional actions that specify a new task. Repeat Combinators. The repeat combinators repeats their argument indefinitely. The combinator rpeatEvery guarantees the given delay between repetitions. The refresh rate is equal to the refresh rate of the current argument task. Only when rpeatEvery waits between the iterations of the argument the refresh interval is equal to the remaining delay time. Other Combinators. The refresh rate of the delay in Eq. 11 is equal to the remaining delay. Refreshing stable tasks can be delayed indefinitely, their value never changes. For other basic tasks, the values from Table 2 apply. The values rl and ru in Eq. 12 are the lower and upper bound of the rate. The refresh intervals associated with various steps of the thermostat program from Listing 3 are given in Table 3. Those rewrite steps and intervals are circular, after step 2 we continue with step 0 again. Only the actual reading of the sensor with temperature dht offers the possibility for a non-zero delay. Table 3. Rewrite steps of the thermostat from Listing 3 and associated intervals. Step Expression 0

rpeat ( temperature dht >>~. \temp. writeD builtInLED (goal >~. \temp. writeD builtInLED (goal >|. rpeat ( temperature dht >>~. \temp. writeD builtInLED (goal >|. rpeat ( temperature dht >>~. \temp. writeD builtInLED (goal >∼. setSds localSds)} Listing 6. Updating an SDS in iTask at most once per minute.

5

Running Tasks on Interrupts

Interrupts can be used in IoT programming to prevent polling of input pins. Most microprocessors offer hardware support to execute a piece of code, called an interrupt service routine (ISR) or handler, associated with a change of input on an input pin. The interrupt handlers are regular functions that typically come with some restrictions, they must be short and should not do any communication. Invoking an ISR often works even when the microprocessor is in sleep mode. Upon the specified change of input level, the system becomes active and executes

Reducing the Power Consumption of IoT with Task-Oriented Programming

91

the ISR. This interrupt mechanism is obviously very suited to reduce the energy consumption of IoT nodes. We can replace polling of an input by installing the appropriate ISR and we do not have to spend any energy to monitor this input. Moreover, it eliminates the possibility that the program misses an event when it occurs in between measurements of the polling cycle. Unfortunately, it is not on all kind of microprocessors possible to determine the reason the system wakes up from deep sleep. Since we cannot uniquely identify interrupts on such a system, the mTask system is limited to light sleep when it has to monitor interrupts. The refresh rate mechanism offers a suitable way to integrate interrupts in mTask. MTask is extended with a basic task called interrupt. It is parameterized by the interrupt mode and the GPIO pin to watch. class interrupts v where interrupt :: InterruptMode (v p)  MTask v Bool | pin p :: InterruptMode = Change | Rising | Falling | Low | High Listing 7. Definition of the interrupt class in mTask.

When the mTask node executes this task, it installs a ISR and sets the refresh rate of the task to infinity, ∞, ∞. The interrupt handler changes the refresh rate to 0, 0 when the interrupt occurs. As a consequence, the task is executed on the next execution cycle. The pirSwitch task in Listing 8 reacts to motion detection by a Passive InfraRed (PIR) sensor by lighting the onboard LED for the given interval. The system lightens the LED again when there is still motion detected after this interval. pirSwitch :: (v Int)  Main (MTask v Bool) | mtask v pirSwitch interval = {main = rpeat ( interrupt High pirPin >>|. writeD BuiltinLEDPin false >>|. delay interval >>|. writeD BuiltinLEDPin true )} Listing 8. Example of a toggle switch with interrupts

By changing the interrupt mode in this program text from High to Rising the system lights the LED only one interval when it detects motion no matter how long this signal is present at the pirPin. This example shows that this abstraction of interrupts is very easy to use and blends well in the TOP design.

6

Implementing Refresh Rates

The refresh rates from the previous section tell us how much the next evaluation of the task can be delayed. An IoT node executes multiple tasks interleaved. In addition, it has to communicate with a server to collect new tasks and updates of SDS. Hence, we cannot use those refresh intervals directly to let the microprocessor sleep. Our scheduler has the following objectives.

92

S. Crooijmans et al.

– Meet the deadline whenever possible, i.e. the system tries to execute every task before the end of its refresh interval. Only too much work on the device might cause an overflow of the deadline. – Achieve long sleep times. Waking up from sleep consumes some energy and takes some time. Hence, we prefer a single long sleep over splitting this interval into several smaller pieces. – The scheduler tries to avoid unnecessary evaluations of tasks as much as possible. A task should not be evaluated now when its execution can also be delayed until the next time that the device is active. That is, a task should preferably not be executed before the start of its refresh interval. Whenever possible, task execution should even be delayed when we are inside the refresh interval as long as we can execute the task before the end of the interval. – The optimal power state should be selected. Although a system uses less power in a deep sleep mode, it also takes more time and energy to wake up from deep sleep. When the system knows that it can sleep only a short time it is better to go to light sleep mode since waking up from light sleep is faster and consumes less energy. The algorithm R from Sect. 4 computes the evaluation rate of the current tasks. For the scheduler, we transform this interval to an absolute evaluation interval; the lower and upper bound of the start time of that task measured in the time of the IoT node. We obtain those bounds by adding the current system time to the bounds of the computed refresh interval by algorithm R. For the implementation, it is important to note that the evaluation of a task takes time. Some tasks are extremely fast, but other tasks require long computations and time-consuming communication with peripherals as well as with the server. These execution times can yield a considerable and noticeable time drift in mTask programs. For instance, a task like rpeatEvery (ExactMs 1) t should repeat t every millisecond. The programmer might expect that t will be executed th for the (N + 1) time after N milliseconds. Uncompensated time drift might make this considerably later. MTask does not pretend to be a hard real-time operating system, and cannot give firm guarantees with respect to evaluation time. Nevertheless, we try to make time handling as reliable as possible. This is achieved by adding the start time of this round of task evaluations rather than the current time to compute absolute execution intervals.

7

Scheduling Tasks

Apart from the task to execute, the IoT device has to maintain the connection with the server and check there for new tasks and updates of SDS. When the microprocessor is active, it checks the connection and updates from the server and executes the task if it is in its execution window. Next, the microprocessor goes to light sleep for the minimum of a predefined interval and the task delay. In general, the microprocessor node executes multiple mTask tasks. Our mTask nodes repeatedly check for inputs from servers and execute all tasks that cannot be delayed to the next evaluation round one step. We store tasks

Reducing the Power Consumption of IoT with Task-Oriented Programming

93

in a priority queue to check efficiently which of them need to be stepped. The mTask tasks are ordered at their absolute latest start time in this queue; the earliest deadline first. We use the earliest deadline to order tasks with equal latest deadline. It is very complicated to make an optimal scheduling algorithm for tasks to minimize the energy consumption. We use a simple heuristic to evaluate tasks and determine sleep time rather than wasting energy on a fancy evaluation algorithm. Listing 9 gives this algorithm in pseudo code. First the mTask node checks for new tasks and updates of SDS. This communication adds any task to the queue. The stepped set contains all tasks evaluated in this evaluation round. Next, we evaluate tasks from the queue until we encounter a task that has an evaluation interval that is not started. This might evaluate tasks earlier than required, but maximizes the opportunities to sleep after this evaluation round. Executed tasks are temporarily stored in the stepped set instead of inserted directly into the queue to ensure that they are evaluated at most once in a evaluation round to ensure that there is frequent communication with the server. A task that produces a stable value is completed and is not queued again. repeat { queue += communicateWithServer; stepped = empty // tasks stepped in this round while (notEmpty queue && earliestDeadline (top queue) ≤ currentTime ) { (task, queue) = pop queue; task2 = step task; // includes computation of new execution interval if (not (isStable task2)) // not finished after step stepped += task2; } queue = merge queue stepped; sleep (queue); } Listing 9. Pseudo code for the evaluation round of tasks in the queue.

The sleep function determines the maximum sleep time based on the top of the queue. The computed sleep time and the characteristics of the microprocessor determine the length and depth of the sleep. For very short sleep times it might not be worthwhile to sleep. In the current mTask RTS, the thresholds are determined by experimentation but can be tuned by the programmer. On systems that lose the content of their RAM it is not possible to go to deep sleep mode.

8

Resulting Power Reductions

For the measurements we used the Adafruit Feather M0 WiFi since it is able to preserve its RAM memory and hence the mTask code and the task queue during deep sleep. The results are representative for any micro controller with similar sleeping characteristics. For the power measurements we used an INA219 current sensor8 configured to measure currents from 0 to 400 mA with a resolution of 8

See https://www.adafruit.com/product/904 for the sensor and https://github.com/ adafruit/Adafruit INA219 for the associated library.

94

S. Crooijmans et al.

0.1 mA. An Arduino Uno controls this sensor and measures the current every two milliseconds. We measure the currents in the power lines of the USB cable powering the microprocessor. The mTask system uses the automatic Arduino power-saving mode for the Wi-Fi module in the old situation, but no other power savings. We compare this with the mTask system extended as described in this paper. Blink Example. The simplest mTask program is the blink function from Listing 1. The microprocessor is always active in the old implementation and hence uses constantly about 15 mA. The LED adds about 1 mA when it is on. The figure on the bottom shows the new implementation, it shows that the system is in light sleep for most of the time. Periodically, the Wi-Fi stack needs to maintain the connection, resulting in current spikes in the graph (Fig. 2).

Fig. 2. Current draw of the blink task with the old implementation on top.

Thermometer Example. The thermometer task just reads the temperature from a Wemos SHT30 sensor9 . thermometer :: Main (MTask v Bool) | mtask v thermometer = DHT I2Caddr λsensor. {main = temperature‘ (BeforeSec (lit 60)) sensor} Listing 10. A basic thermometer task.

The measurement will be repeated since this produces an unstable value. Figure 3 depicts the current consumption of this example. The longer upper 9

See https://www.wemos.cc/en/latest/d1 mini shield/sht30.html.

Reducing the Power Consumption of IoT with Task-Oriented Programming

95

bound of the refresh interval enables deep sleep between individual measurements. As a consequence, the Wi-Fi connection needs to be re-established after waking up, resulting in a longer current spike.

Fig. 3. Current draw of the temperature task with the old implementation on top.

PIR Switch Example. Next, we consider the PIR switch example from Listing 8. Since the old version of mTask does not support interrupts, we cannot execute the program directly. To mimic the effect as well as possible, we replaced interrupt High pirPin by a polling solution that waits until the pin reading is high; readD pirPin >>*. [IfValue id rtrn]. The measurement results are similar to the previous example, constant activity in the old implementation and sleep with small bursts in the new implementation. The actual interrupts determine the number of bursts seen so it does not make sense to plot the current. Plant Monitor. This task observes a plant’s environment and monitors two values, the amount of water in the soil and the light intensity. It becomes stable when one of the monitored values exceeds a threshold. The value of the soil sensor should be read at least every 5 min, 300 s, and the light intensity at least every 90 s. monitorPlant :: Main (MTask v Bool) | mtask, dht, LightSensor v monitorPlant = {main = lightSensor .||. moistureSensor} where moistureSensor = readA‘ (BeforeSec (lit 300)) MoistureSensorPin >>*. [IfValue ((>*. [IfValue (( 0 then 3 else 2

↦→ ↦→

are not contextually equivalent. Take the context 𝐸 [·] : I NT with 𝐸 [·] = · 0, then: 𝐸 [𝑒 1 ] = (𝜆𝑥 : I NT . if 𝑥 < 0 then 2 else 3) 0 3 𝐸 [𝑒 2 ] = (𝜆𝑥 : I NT . if 𝑥 > 0 then 3 else 2) 0

2

So we found a context for which both expressions evaluate to a different (basic) value.

5

Task Conditions

For expression equivalence, we needed contexts to determine the equivalence of two lambda functions, whose results can only be observed after evaluation. For tasks however, we do not need contexts to view their results. A task’s value can be determined at any point during execution by using the V observation, whereupon it either has a value, or it is undefined. Note that, we will restrict ourselves to tasks which result in a basic value, as these are results which are directly comparable. On the other hand, tasks do allow user interaction, and depending on what inputs are sent, the resulting task may be different. So while lambda functions

Semantic Equivalence of Task-Oriented Programs in TopHat

111

can produce different results depending on their arguments, so can tasks produce different results depending on what inputs are send to them. So, in a sense, for tasks the contexts are user input. To satisfy Property 1 of Sect. 3, which says that programs resulting in values which are observably different must not be equivalent, we will use the observations on tasks, as introduced in Sect. 2.4. Before we observe a task however, we should first fully fixate the tasks whose equivalence we want to determine. We use the fixing semantics (⇓) to ensure that tasks are fully fixated. Fixation keeps track of a state 𝜎. For semantic equivalence, we do not want that fixation results in two different states. So, given two tasks 𝑡 1, 𝑡 2 : TASK 𝛽, we want that for all states 𝜎, fixation of 𝑡 1 and 𝑡 2 end in the same state 𝜎 : 𝑡 1, 𝜎, ∅ ⇓ 𝑡 1 , 𝜎 ⇐⇒ 𝑡 2, 𝜎, ∅ ⇓ 𝑡 2 , 𝜎

After fixation, we need to decide what observations 𝑡 1 and 𝑡 2 must have in common for them to be considered equivalent. Let us recall what observations can be made on tasks. The value function V returns the value 𝑣 of a task, or ⊥ if it is undefined; there is the failing function F which returns whether a task is failing; and there is the inputs function I which returns the set of all possible input events that a task accepts, that is, their input space. The value and failing functions are used in the normalisation rules of TopHat, and different observations for these functions can result in different derivation rules being triggered. Therefore, we can say that tasks for which the value or failing function return a different result must not be semantically equivalent. Similarly, tasks whose inputs function I return a different set of input events can also not be semantically equivalent, because that would mean that the types of interaction that can be done with the tasks are different. Recall that input events should be named by the same key as the editor it is meant for. So if we require that I (𝑡 1 ) = I (𝑡 2 ), then this also implies that 𝑡 1 and 𝑡 2 must have the same keys for all their editors. If this is not the case, we can never have that I (𝑡 1 ) = I (𝑡 2 ), and the tasks cannot be semantically equivalent. Given these impressions, we say that at least the following property must hold for 𝑡 to be equivalent to 𝑡 : F (𝑡) = F (𝑡 ) and V (𝑡, 𝜎) = V (𝑡 , 𝜎) and I (𝑡) = I (𝑡 ) For any of these task observations, we can distinguish two cases: either a task fails or does not fail, it either has a value or its value is undefined, and it either accepts input or it accepts no more input. Based on these case distinctions, we say that a task is in either one of five conditions after fixation. These task conditions, and some examples, are shown in Table 1. In the next subsections, we will discuss each condition in more detail and give some examples. 5.1

Failing Tasks

A failing task 𝑡 is a task for which the failing function F (𝑡) yields true.

112

T. Klijnsma and T. Steenvoorden Table 1. Conditions for fixated tasks F V I Condition

Examples

 – – Failing ,   ,   , (𝜆𝑥 .𝑥) • ,   𝜆𝑥 .  ◦𝑥  ◦ 2,  ◦ 2   ◦ 3,  ◦ {2, 3}, (𝜆𝑥 .𝑥 + 1) •  ◦2 –  – Finished steady –   Finished unsteady 2, 2  3, {2, 3} I NT  , 2  𝜆𝑥 . – –  Running  ◦ 2  𝜆𝑥 .,  ◦ 2  ,    ◦2 – – – Stuck Checkmarks for F , V and I for a task 𝑡 and a state 𝜎 indicate that F (𝑡) = True, V (𝑡, 𝜎) = 𝑣 for 𝑣 ≠ ⊥, and I (𝑡) ≠ ∅ respectively.

In the original TopHat paper [13], Theorem 6.5 states that a task fails if and only if it accepts no more user input. However, with the introduction of the lift combinator () in Steenvoorden [12], this is no longer the case. For example, 𝑒 does not fail, and neither does it accept user input. The same holds for readonly editors ( ◦,  ◦ ). What we still can say however, is that if a task is failing, we know that it has no value and accepts no more user input. We have proven this property in in the appendix. Proposition 1 (Failing tasks stay failing). If 𝑡 is a failing task, then it stays failing. Or, more formally: for all fixated tasks 𝑡 : TASK 𝜏 and states 𝜎, we have 𝜄 that if F (𝑡), there is no input 𝜄 such that 𝑡, 𝜎 =⇒ 𝑡 , 𝜎

. Proof. Once a task fails, it will always remain failing, because by Corollary 3 from the appendix no more user interaction is possible, and by assumption, the task is already fixated. Failing can thus be regarded as one type of termination, and we will consider all tasks that fail to be equivalent. Hence, we will say that the tasks ,   ,   , (𝜆𝑥 . 𝑥) • ,   𝜆𝑥 .  ◦ 𝑥, and all other failing tasks, are semantically equivalent to each other. 5.2

Finished Tasks

A finished task 𝑡 is a task which yields a value V (𝑡, 𝜎) = 𝑣 for 𝑣 ≠ ⊥. This value can either be steady when no more user input is possible, or unsteady when the task still accepts user input. An example of a finished task with a steady value is the task  ◦ 𝑘 42. Because this task accepts no more user input, its value will always remain equal to 42. An example of a finished task with an unsteady value is the task 𝑘 42. This task still accepts user input, and thus its value can keep on changing. Even though both tasks yield the same value, they should not be equivalent, since one’s value can be changed and the other one’s value cannot. Definition 2 (Finished, steady, and unsteady task). We call a fixated task 𝑡 : TASK 𝜏 in state 𝜎 finished if and only if V (𝑡, 𝜎) = 𝑣, for 𝑣 ≠ ⊥. Furthermore, we call a finished task steady if and only if I (𝑡) = ∅, and unsteady in the other case.

Semantic Equivalence of Task-Oriented Programs in TopHat

113

A steady task can thus never be semantically equivalent to an unsteady task, even if their values are (initially) the same. But just looking at the resulting values, and whether the resulting value is steady or not, is not enough to determine semantic equivalence of finished tasks. Consider for example the following tasks: ◦ {2, 3} 𝑡 3 := 𝑘1 {2, 3} 𝑡 1 :=  𝑡 2 :=  ◦ 2   ◦ 3 𝑡 4 := 𝑘1 2  𝑘2 3 These are all finished tasks with value {2, 3}. Tasks 𝑡 1 and 𝑡 2 are both steady, and thus 𝑡 1  𝑡 2 . We have already concluded that steady tasks cannot be equivalent to unsteady tasks, so 𝑡 1  𝑡 3 , 𝑡 1  𝑡 4 , 𝑡 2  𝑡 3 , and 𝑡 2  𝑡 4 . But we will also say that 𝑡 3  𝑡 4 , because we have that: I (𝑡 3 ) = {𝑘 1 !𝑏 1 | 𝑏 1 : I NT × I NT } I (𝑡 4 ) = {𝑘 1 !𝑏 1 | 𝑏 1 : I NT } ∪ {𝑘 2 !𝑏 2 | 𝑏 2 : I NT } Meaning that the types of interaction that can be done with 𝑡 3 differ from the types of interaction that can be done with 𝑡 4 . In the case of 𝑡 3 , the user can only alter the pair in one go, whereas for 𝑡 4 , the user can partially update it. So for finished tasks, we also need to require that I (𝑡) = I (𝑡 ) if we want to conclude that 𝑡  𝑡 . And yet this is still not enough... Consider the following two tasks: 𝑡 5 := (𝜆𝑥 . if 𝑥 < 0 then −𝑥 else 𝑥) • 𝑘 42 𝑡 6 := 𝑘 42 Both tasks have the value 42 and accept the same input. However for negative values, the resulting values diverge, because the transform function in task 𝑡 5 ensures that its output is always positive. So if a task still accepts user input, not only do we need to look at a task’s current value, but we also need to consider all values that a task can have after user interaction. We will regard finished tasks as another type of condition a task can be in after fixation. Of course, if the value is unsteady, the task is not terminated in the true sense of the word, since there is still user interaction possible. In fact, the task will never terminate: a finished task will always remain a finished task with the same input space, only its value may change. Proposition 2 (Finished tasks stay finished). If 𝑡 is a finished task, then 𝜄 for all inputs 𝜄 ∈ I (𝑡): if 𝑡, 𝜎 =⇒ 𝑡 , 𝜎 , then 𝑡 is again a finished task. Moreover, we also have that I (𝑡 ) = I (𝑡). Proof. Because 𝑡 is finished, it is fixated and has a value by Definition 2. Therefore, by Proposition 14 from the appendix, we know that 𝑡 is a static task. That is, it is a task which does not change the combinators it utilises. Using the properties of static tasks, as proved in the appendix, we know 𝑡 stays static by Corollary 4, and its inputs will stay the same over interactions by Corollary 5.

114

5.3

T. Klijnsma and T. Steenvoorden

Stuck Tasks

A stuck task 𝑡 is a task which does not fail, does not have a value, and does not accept user input. Examples of stuck tasks are  ◦ 2  𝜆𝑥 . ,  ◦ 2  , and    ◦ 2. The first example is stuck because the right-hand side always fails, and thus the step can never be taken. For pairing, we have that both sides must fail before  fails, and both sides must have a value before  has a value. So, if one side fails and the other side has a value, then neither observation is true, and the task is stuck. Definition 3 (Stuck task). We call a fixated task 𝑡 : TASK 𝜏 in state 𝜎 stuck if and only if ¬F (𝑇 ), V (𝑡, 𝜎) = ⊥, and I (𝑡) = ∅. A stuck task will always remain stuck, because no more user interaction is possible, and by assumption it is already fully fixated. Proposition 3 (Stuck tasks stay stuck). If 𝑡 is a stuck task, then it stays stuck. Or, more formally: for all fixated tasks 𝑡 : TASK 𝜏 and states 𝜎, we have 𝜄 that if I (𝑡) = ∅, there is no input 𝜄 such that 𝑡, 𝜎 =⇒ 𝑡 , 𝜎 . Similar to failing, we will consider stuck tasks as another type of termination, and we will say that all stuck tasks are semantically equivalent to each other. 5.4

Running Tasks

A running task 𝑡 is a task which does not fail, does not have a value, but still accepts user input. Because there is still user interaction possible, it may be the case that with the right input, it transitions to one of the previously described task conditions. The simplest example of a running task is the empty editor 𝑘 𝛽, which becomes a finished (unsteady) task once it receives a valid input event. There also exist running tasks that only transition to another task condition for some inputs, or for no inputs at all. For example, the tasks 𝑘 I NT  𝜆𝑥 . , 𝑘  , and   𝑘 2 are all running tasks which will forever remain running; ◦ 𝑥 else  will only transition to another and the task 𝑘 I NT  𝜆𝑥 . if 𝑥 ≤ 2 then  task condition for some inputs, but not for others. Definition 4 (Running task). We call a fixated task 𝑡 : TASK 𝜏 in state 𝜎 running if and only if ¬F (𝑇 ), V (𝑡, 𝜎) = ⊥, and I (𝑡) ≠ ∅. To determine the equivalence of two running tasks, we therefore need to look at all possible user interactions, and check that they affect the two tasks in the same way. To do this, we need a way to talk about sequences of input events, instead of just single input events. We give the following definition for this: Definition 5 (Input sequences). An input sequence 𝐼 = 𝜄 1 · . . . · 𝜄 𝑗 is a finite sequence of input events. Given a task 𝑡 0 : TASK 𝜏 and a state 𝜎0 , we say that 𝐼 is a valid input sequence for 𝑡 0 in 𝜎0 if and only if: 𝜄1

𝜄2

𝜄𝑗

𝑡 0, 𝜎0 =⇒ 𝑡 1, 𝜎1 =⇒ . . . =⇒ 𝑡 𝑗 , 𝜎 𝑗

Semantic Equivalence of Task-Oriented Programs in TopHat

with 𝜄𝑖 ∈ I (𝑡𝑖−1 ), for all 𝜄 ∈ {1, . . . , 𝑗 }.

115

𝐼

We will use the shorthand notation 𝑡 0, 𝜎0 =⇒∗ 𝑡 𝑗 , 𝜎 𝑗 to denote the above derivation. We also consider the empty input sequence, denoted by 𝜖, to be a valid input 𝜖 sequence, and for all tasks 𝑡 and states 𝜎 we have that: 𝑡, 𝜎 =⇒∗ 𝑡, 𝜎. We will make a distinction between running tasks that forever remain running, no matter what inputs you send to it; and running tasks for which there exists at least one input sequence which escapes, i.e. which transitions to another task condition. We will call the former class looping tasks, and the latter class branching tasks, formally: Definition 6 (Looping and branching). We call a running task 𝑡 : TASK 𝜏 in state 𝜎 looping if and only if for all valid input sequences 𝐼 we have that 𝐼

𝑡, 𝜎 =⇒∗ 𝑡 , 𝜎 and 𝑡 is again a running task. If there exists at least one valid input sequence for which 𝑡 is not a running task, then we call 𝑡 branching. For branching tasks, it is possible to transition to either a finished or a stuck task condition. Take for example the task 𝑘 I NT  𝜆𝑥 . 𝑡, which is a running task that transitions to 𝑡 after a valid input event. So long as 𝑡 is not failing, the step can be taken, and so 𝑡 can be any non-failing task. It is also possible that for some input events, it transitions to one task condition, and for others, that it transitions to a different task condition. For example the task 𝑘 I NT  𝜆𝑥 . if 𝑥 ≤ 2 then 𝑡 else 𝑡 transitions to 𝑡 for some inputs, and to 𝑡 for other inputs. However, a running task can never transition to a failing task. Proposition 4 (Running tasks will not fail). If 𝑡 : TASK 𝜏 is a running task, then for all states 𝜎 and for all valid input sequences 𝐼 , we have that if 𝐼

𝑡, 𝜎 =⇒∗ 𝑡 , 𝜎 then ¬F (𝑡 ). Proof. By assumption 𝑡 is running, and therefore itself not failing, and by the definition of the normalisation rules, 𝑡 cannot make a transition to a failing task. We say that two running tasks 𝑡 1 and 𝑡 2 in state 𝜎 are semantically equivalent 𝐼

𝐼

if for all valid input sequences 𝐼 we have that 𝑡 1, 𝜎 =⇒ 𝑡 1 , 𝜎 ⇐⇒ 𝑡 2, 𝜎 =⇒ 𝑡 2 , 𝜎 , with V (𝑡 1 , 𝜎 ) = V (𝑡 2 , 𝜎 ), and I (𝑡 1 ) = I (𝑡 2 ). That is, for all possible user interactions, 𝑡 1 and 𝑡 2 are not observably different. Because of Proposition 4, we do not need to check whether the tasks fail or not.

6

Task Equivalence

Figure 5 shows all possible task states and their transitions. In this diagram, a looping task is a task which never leaves the running state, that is, which always takes the transition back to the running state, no matter what input is given. A branching task is a running task for which there exists at least one input sequence which will transition to either stuck, steady, or unsteady. We will argue that this state diagram is complete. First, we show that the five conditions discussed so far are mutually exclusive and exhaustive.

116

T. Klijnsma and T. Steenvoorden

Fig. 5. Possible task conditions and their transitions, where a transition is caused by 𝜄 user interaction (=⇒) for some input 𝜄.

Proposition 5 (Condition exclusivity). For all well typed fixated tasks 𝑡 and states 𝜎, we have that 𝑡 is in one of the five conditions show in Fig. 5. Proof. By Proposition 12, we know that if a task is failing, it has no observable value and no possible inputs. This makes Failing the only condition a task can be in when F (𝑡) = True. Otherwise, one of the four remaining conditions (Steady, Unsteady, Running, and Stuck ) should hold. These four conditions are mutual exclusive by definition. Now that we know the conditions in Fig. 5 are the only conditions for any fixated task to be in, we show that the transitions in the state diagram are also complete. Proposition 6 (Condition completeness). The state diagram in Fig. 5 is complete. That is, for any two task conditions 𝐶 and 𝐶 , there is a transition from 𝐶 to 𝐶 if and only if there exist two tasks 𝑡 ∈ 𝐶 and 𝑡 ∈ 𝐶 such that 𝜄 𝑡, 𝜎 =⇒ 𝑡 , 𝜎 for some input 𝜄 ∈ I (𝑡) and states 𝜎 and 𝜎 . Proof. For the conditions in the state diagram of Fig. 5 we have: – Running tasks can branch to all other conditions, except for the failing condition, as stated in Proposition 4, or loop to itself. – Failing tasks stay failing, by Proposition 1. – Stuck tasks stay stuck, by Proposition 3. – Finished tasks stay finished, by Proposition 2. Moreover, they keep their input space. So steady tasks stay steady, because their input space stays empty, and unsteady tasks stay unsteady, keeping their non-empty input space. Hereby, we covered every possible transition in Fig. 5. In the previous section, we mentioned that with the addition of internal editors () and read-only editors ( ◦,  ◦ ) in this paper, it is no longer the case that a task 𝑡 is failing if and only if it accepts no more user input, as is shown in

Semantic Equivalence of Task-Oriented Programs in TopHat

117

Theorem 6.5 in the original TopHat paper [13]. Were this theorem still true, we could not have the stuck and steady states, because they contain tasks that do not fail and do not accept user input. We will therefore claim that, if we remove ,  ◦ , and  ◦ from the TopHat language as presented here, we no longer have the stuck and steady conditions. Corollary 1 (Read-only tasks). If we remove the editors ,  ◦ , and  ◦ from TopHat, we are left with only three possible task conditions after fixation: failing, running and finished unsteady. Based on the five task conditions, we give the following definition for the semantic equivalence of two tasks: Definition 7 (Task equivalence). Given two fixated tasks 𝑡 1, 𝑡 2 : TASK 𝛽 of basic inner type, and given a state 𝜎, we say that 𝑡 1 and 𝑡 2 are semantically equivalent if one of the following holds: 1. both tasks are failing; 2. both tasks are finished with V (𝑡 1, 𝜎) = V (𝑡 2, 𝜎), and this value is steady; 3. both tasks are finished with V (𝑡 1, 𝜎) = V (𝑡 2, 𝜎), and this value is unsteady, 𝐼

𝐼

and for all valid input sequences 𝐼 : 𝑡 1, 𝜎 =⇒ 𝑡 1 , 𝜎 ⇐⇒ 𝑡 2, 𝜎 =⇒ 𝑡 2 , 𝜎 with V (𝑡 1 , 𝜎 ) = V (𝑡 2 , 𝜎 ); 4. both tasks are stuck; 𝐼

5. both tasks are running, and for all valid input sequences 𝐼 : 𝑡 1, 𝜎 =⇒ 𝑡 1 , 𝜎 ⇐⇒ 𝐼

𝑡 2, 𝜎 =⇒ 𝑡 2 , 𝜎 with V (𝑡 1 , 𝜎 ) = V (𝑡 2 , 𝜎 ) and I (𝑡 1 ) = I (𝑡 2 ). We write 𝑡 1  𝑡 2 . To determine a task’s condition, we use the task observation functions F , V, and I. For some task conditions, we also need to take the interactive setting of TopHat into account. Namely, if the set of inputs given by I is not empty, as is the case for the unsteady and running task states, we also need to check the task observations after every possible input sequence. Because we also allow input sequences to be empty, we can generalise the above definition as follows: Definition 8 (Generalised task equivalence). Given two fixated tasks 𝑡 1, 𝑡 2 : TASK 𝛽 of basic inner type, and given a state 𝜎, we say that 𝑡 1 and 𝑡 2 are semantically equivalent (𝑡 1  𝑡 2 ) if for all valid input sequences 𝐼 we have that 𝐼

𝐼

𝑡 1, 𝜎 =⇒ 𝑡 1 , 𝜎 ⇐⇒ 𝑡 2, 𝜎 =⇒ 𝑡 2 , 𝜎 with F (𝑡 1 ) = F (𝑡 2 ), V (𝑡 1 , 𝜎 ) = V (𝑡 2 , 𝜎 ), and I (𝑡 1 ) = I (𝑡 2 ).

7

Laws on Tasks

Now that we have a definition of semantic equivalence for TopHat-programs, we can look at some interesting properties. This section gives some transformation properties that hold (or not) for task equivalence.

118

T. Klijnsma and T. Steenvoorden

Whenever we write a task equivalence, we implicitly universally quantify over tasks 𝑡. So 𝑡 1  𝑡 2 , where tasks 𝑡 1 and 𝑡 2 contain task 𝑡, should be read as ∀𝑡 : TASK 𝜏 . 𝑡 1  𝑡 2 . Moreover, whenever we write a task inequivalence, then we mean that there exists at least one task 𝑡 for which the equivalence does not hold. So 𝑡 1  𝑡 2 , where tasks 𝑡 1 and 𝑡 2 contain task 𝑡, should be read as ¬∀𝑡 : TASK 𝜏 . 𝑡 1  𝑡 2 , or ∃𝑡 : TASK 𝜏 . 𝑡 1  𝑡 2 . For some (in)equalities we may use additional tasks, expressions, or functions, in which case these (in)equalities should be interpreted in the same way. In TopHat, we can express functor laws over the type constructor TASK as follows. Proposition 7 (Laws on transform (•)). id • 𝑡  𝑡 (𝑒 1 ◦ 𝑒 2 ) • 𝑡  𝑒 1 • (𝑒 2 • 𝑡)

identity composition

Proof. Given any fixated task 𝑡 and state 𝜎, we have that

↦→

– F (id • 𝑡) = F (𝑡) by definition (see Fig. 1); – I (id • 𝑡) = I (𝑡) by definition (see Fig. 1); and – V (id • 𝑡, 𝜎) = V (𝑡, 𝜎), because if 𝑡 has no value, then neither does id • t, and if V (𝑡, 𝜎) = 𝑣 for 𝑣 ≠ ⊥, then V (id • 𝑡, 𝜎) = id 𝑣 𝑣. A similar argumentation can be made for the composition law. Therefore the TASK operator on types is a functor. For the TopHat pairing construct (), we can formulate laws which are inspired by the monoidal functor formulation of the laws of Haskell’s Applicative type class [5]. Proposition 8 (Laws on pair ()). {}  𝑡  𝑡 𝑡  {}  𝑡 assoc • (𝑡 1  (𝑡 2  𝑡 3 ))  (𝑡 1  𝑡 2 )  𝑡 3 {𝑒 1 _, 𝑒 2 _} • (𝑡 1  𝑡 2 )  (𝑒 1 • 𝑡 1 )  (𝑒 2 • 𝑡 2 )

left identity right identity associativity naturality

Proof. If we take 𝑡 =  for the left and right identity laws, then in both cases we have that the right-hand side is a failing task, but the left-hand side is stuck. Pairing requires that both sides fail before it fails itself. Therefore, both the left and right identity laws do not hold. However, the other two laws hold using a similar argumentation as in the proof of Proposition 7. For associativity, we need the function assoc := 𝜆{𝑎, {𝑏, 𝑐}}.{{𝑎, 𝑏}, 𝑐} to make sure that both sides have the same type. Also, the naturality law states that first pairing two tasks and then mapping two functions is the same as first mapping the functions separately and then pairing them.

Semantic Equivalence of Task-Oriented Programs in TopHat

119

For choosing () we formulate the following properties, which are loosely based on the laws of the Alternative type class in Haskell. Proposition 9 (Laws on choose ()). 𝑡 1  (𝑡 2  𝑡 3 )  (𝑡 1  𝑡 2 )  𝑡 3 𝑒  𝑡  𝑒

associativity left catch

𝑡  𝑒  𝑒 𝑒 • (𝑡 1  𝑡 2 )  (𝑒 • 𝑡 1 )  (𝑒 • 𝑡 2 ) 𝑡 0  (𝑡 1  𝑡 2 )  (𝑡 0  𝑡 1 )  (𝑡 0  𝑡 2 )

right catch distributivity left pair

(𝑡 1  𝑡 2 )  𝑡 0  (𝑡 1  𝑡 0 )  (𝑡 2  𝑡 0 ) 𝑡 0  𝜆𝑥 .(𝑡 1  𝑡 2 )  (𝑡 0  𝜆𝑥 .𝑡 1 )  (𝑡 0  𝜆𝑥 .𝑡 2 ) (𝑡 1  𝑡 2 )  𝑒  (𝑡 1  𝑒)  (𝑡 2  𝑒)

right pair left step right step

Proof. Associativity, left catch, and a number of distributivity laws can be proved in a similar way as in Proposition 7. Catch only holds for the left side, because the choice combinator is left-biased. The first distributivity law shows that mapping a function over choice should be equivalent to mapping it separately over its component subtasks. The last two distributivity laws for step () show that stepping to or from a choice is equivalent to choosing between steps. Left and right distributivity for pairing () do not hold. Suppose V (𝑡 0, 𝜎) = ⊥, V (𝑡 1, 𝜎) = 𝑣 1 and V (𝑡 2, 𝜎) = 𝑣 2 . Then in both cases, we have that the lefthand side of the inequality has no value, because pairing requires that both sides have a value before it has a value itself. However, the right-hand side does have a value in both cases, namely {𝑣 1, 𝑣 2 }, because the choice combinator normalises 𝑡 0 away, and we are left with 𝑡 1  𝑡 2 in both cases. When considering the failing task (), we can formulate the following laws: Proposition 10 (Laws on fail ()).   𝑡  𝑡

left identity

𝑡    𝑡 𝑒 •  

right identity annihilation

  𝑡   𝑡    

left zero right zero

𝑒   𝑡  𝜆𝑥 .  

left annihilation right annihilation

Proof. The failure task () acts as the left and right identity for the choice combinator (). That means that  can be cancelled out from the left- and right-hand side of . For pairing, left and right zero do not hold, because by definition F (𝑡 1  𝑡 2 ) = F (𝑡 1 ) ∧ F (𝑡 2 ) (see Fig. 1). That is, pairing requires that both the left-hand and right-hand side fail before their pairing fails. Thus if only one side fails, it cannot be equivalent to the failing task .

120

T. Klijnsma and T. Steenvoorden

We defined all failing tasks to be semantically equivalent, thus annihilation for •, and left step annihilation for  trivially hold. Right step annihilation does not however. Suppose that 𝑡 is not a failing task, then we also have that 𝑡  𝜆𝑥 . is not a failing task, because F (𝑡 𝜆𝑥 .) = F (𝑡) by definition (see Fig. 1). Neither can we normalise 𝑡  𝜆𝑥 . , because the right-hand side fails. So if 𝑡 does not fail, then neither does 𝑡  𝜆𝑥 ., and we cannot conclude that a non-failing task is semantically equivalent to the failing task . Steps () in TopHat have a monadic flavour to them, and we can wonder whether the step combinator is a bind operation. So, if we consider TASK to be the monadic type constructor,  the return function, and  the bind function, then we can express the three monadic laws in TopHat as follows. Proposition 11 (Laws on step ()). 𝑥  𝑔  𝑔 𝑥 𝑡  (𝜆𝑦.𝑦)  𝑡 (𝑡  𝑔)  ℎ  𝑡  (𝜆𝑦.𝑔 𝑦  ℎ)

left identity right identity associativity

↦→

Proof. With our definition of program equivalence, we can show that neither the left nor the right identity laws hold. For the left identity, take for example 𝑔 := 𝜆𝑥 ., then we have that 𝑔 𝑥 is a failing task, because 𝑔 𝑥 = (𝜆𝑥 .) 𝑥 . However, the left-hand side 𝑥  𝑔 = 𝑥  𝜆𝑥 . is a stuck task, because it does not fail, and the step can never be taken. For the right identity law we can take for example 𝑡 := 𝑘 I NT. If we send the input event 𝑘!42 to both sides, then the left-hand side normalises to 42, while the right-hand side becomes 𝑘 42. As we have already seen, these two tasks are not semantically equivalent, because the value of 42 is steady and cannot be changed any more, while the value of 𝑘 42 is unsteady and can be updated through user input. However, the associativity law holds. Given any fixated task 𝑡 and state 𝜎, for F and I we have that:     F (𝑡  𝑔)  ℎ = F (𝑡  𝑔) = F (𝑡) = F 𝑡  (𝜆𝑦.𝑔 𝑦  ℎ)     I (𝑡  𝑔)  ℎ = I (𝑡  𝑔) = I (𝑡) = I 𝑡  (𝜆𝑦.𝑔 𝑦  ℎ) ↦→

by definition (see Fig. 1). Furthermore, supposing that V (𝑡, 𝜎) = 𝑣, and 𝑔 𝑣 𝑡

with ¬F (𝑡 ), then both sides normalise to 𝑡  ℎ in some state 𝜎 . On the other hand, if either V (𝑡, 𝜎) = ⊥ or F (𝑡 ), then the step cannot be taken, and we have that V (𝑡  𝑔)  ℎ = V 𝑡  (𝜆𝑦.𝑔 𝑦  ℎ) = ⊥.

8

Conclusions

In this paper, we took TopHat as a foundation to reasoning about task-oriented programs. We gave a definition for the semantic equivalence of two TopHatprograms. We split this definition into two classes: expression equivalence, and

Semantic Equivalence of Task-Oriented Programs in TopHat

121

task equivalence. For task equivalence, we showed that a task can be in either one of five conditions after fixation, and for every two tasks in the same condition, we defined what it means for them to be semantically equivalent. We also noted that for task conditions that still accept user input, it is important to take the interactive setting of TopHat into account, and compare how both tasks react to user input. We presented a set of transformation laws for TopHatprograms. Especially, we showed that the TASK type constructor in TopHat is not a monad but it is a functor. Using these laws, developers and compilers can do safe transformations on task-oriented programs, while being sure the semantic meaning of the program stays the same. Future Work In future work, we would like to implement the transformation laws presented in Sect. 7 in TopHat’s symbolic execution engine [7]. This could speed up symbolic evaluation and give faster results on next step hints for end users. Also, we would like to investigate the implications of our findings on the iTasks system, which could benefit from implementing the transformation laws in the same way. Regrettably, our previous choice in [13] to make pairing tasks (𝑡 1  𝑡 2 ) fail when both 𝑡 1 and 𝑡 2 fail, implies that the TASK type constructor does not form an applicative functor. This would be the case when we’d define F (𝑡 1  𝑡 2 ) = F (𝑡 1 ) ∨ F (𝑡 2 ). More research is needed to gain information on the impact of this change on the rest of the formalisation. Acknowledgements. The authors like to thank Herman Geuvers for supervising the bachelor research on which this article is based.

A

Appendix

In this section we prove some properties of our observations functions introduced in Sect. 2.4 and show that they play well together. A.1

Failing

First, we prove that our failing observation F indeed observes a task that does not have a value, and does not have possible inputs. That is, the value observation V is undefined, and the inputs observation I gives the empty set. Proposition 12 (Failing tasks cannot have interaction). For all well typed normalised tasks 𝑛 and state 𝜎, we have that if F (𝑛) = True, then V (𝑛, 𝜎) = ⊥ and I (𝑛) = ∅. Proof. This proposition is mechanically proved by induction over 𝑛 in Idris, see Task.Proofs.Failing.failingMeansNoInteraction.

122

T. Klijnsma and T. Steenvoorden

Note that the converse statement is not true: a task which does not have a value and does not have possible inputs is not necessarily failing. The task  ◦ 42   does not have a value, because the right hand side of the pair combinator does not have a value; and does not have possible inputs, because the left hand side is a read-only editor. However, F ( ◦ 42  ) = False, because the failing observation needs both sides of the pair to be failing, but the left hand side clearly is not. This deviates from the properties from earlier TopHat versions [7,13]. A.2

Inputs

Next, we validate our inputs observation I. It calculates all possible inputs for a given task. We need to show that the set of possible inputs it produces is both sound and complete with respect to the handling (−→) and interaction (=⇒) semantics. By sound we mean that all inputs in the set of possible inputs can actually be handled by the semantics, and by complete we mean that the set of possible inputs contains all inputs that can be handled by the semantics. Proposition 13 expresses exactly this property for the handling semantics. Proposition 13 (Inputs is sound and complete (w.r.t. handle)). For all well typed normalised tasks 𝑛, states 𝜎, and inputs 𝜄, we have that 𝑖 ∈ I (𝑛) if and 𝜄 only if there exists task 𝑡 , state 𝜎 , and dirty set 𝛿 such that 𝑛, 𝜎 −→ 𝑡 , 𝜎 , 𝛿 . Proof. This theorem is mechanically proved by induction on 𝑛 in Idris, see Task.Proofs.Inputs.inputIsHandled and Task.Proofs.Inputs. handleIsPossible. Because of the structure of the interaction semantics (=⇒), Proposition 13 directly gives us soundness and completeness of inputs with respect to interact. Corollary 2 (Inputs is sound and complete (w.r.t. normalise)). For all well typed normalised tasks 𝑛, states 𝜎, and inputs 𝜄, we have that 𝑖 ∈ I (𝑛) if 𝜄 and only if there exists normalised task 𝑛 , and state 𝜎 such that 𝑛, 𝜎 =⇒ 𝑛 , 𝜎 . By combining Proposition 12 and Corollary 2, we know that failing tasks cannot handle input. Corollary 3 (Failing tasks cannot handle input). For all well typed normalised tasks 𝑛 and states 𝜎, we have that if F (𝑛), there is no input 𝜄 such that 𝜄 𝑛, 𝜎 −→ 𝑡 , 𝜎 , 𝛿 . A.3

Value

Last, we prove that, when we can observe a value from a task, this task is a static task. By static tasks, we mean that the shape of the tree of task nodes and leaves are static and cannot be dynamically altered at runtime.

Semantic Equivalence of Task-Oriented Programs in TopHat

123

Fig. 6. Static tasks

Definition 9 (Static task). We call tasks static when they consist of a valued editor, pairs of static tasks, or transforms of static tasks. That is, they confirm to the grammar presented in Fig. 6. Note that static tasks are a subset of normalised tasks. Proposition 14 (Valued tasks are static). For all well typed normalised tasks 𝑛 and states 𝜎, we have that if V (𝑛, 𝜎) = 𝑣, then 𝑛 is static. Proof. We have that V (𝑛, 𝜎) = 𝑣 : 𝛽. When taking the definition of V into account (as given in Fig. 2), we see that 𝑛 can only be in one of four shapes: – – – –

a a a a

valued editor 𝑑 (that is 𝑑 ≠ 𝛽); pair 𝑛 1  𝑛 2 , for which V (𝑛 1, 𝜎) = 𝑣 1 and V (𝑛 2, 𝜎) = 𝑣 2 ; transform 𝑒 1 • 𝑛 2 , for which V (𝑛 2, 𝜎) = 𝑣 2 ; lift 𝑣.

Especially, step (𝑛 1  𝑒 2 ) and fail () are ruled out, because they have no value. Choice (𝑛 1 𝑛 2 ) is ruled out, because by rules N-Choose[Left,Right,None], the only way that this combinator can survive normalisation is for both 𝑛 1 and 𝑛 2 to not have a value, which should be the case here. Now, by induction, we have that 𝑛 is static and meets the grammar in Fig. 6. See also Task.Proofs.Static.valued_means_static in Idris. As static tasks do not contain steps (𝑛 1  𝑒 2 ) or choices (𝑛 1  𝑛 2 ), their form cannot be altered at runtime. That is, there is no way for end users to create new (sub)tasks. Corollary 4 (Static tasks stay static). For all well typed normalised tasks 𝑛 and states 𝜎, we have that if 𝑛 is static, its shape cannot be altered at runtime. In particular, we cannot create or delete editors at runtime. So the input events a static task can process will always be the same. Corollary 5 (Static tasks keep input observation). For all well typed nor𝜄 malised tasks 𝑛 and states 𝜎, we have that if 𝑛 is static, and 𝑛, 𝜎 =⇒ 𝑛 , 𝜎 for

some 𝜄 ∈ I (𝑛) then I (𝑡) = I (𝑡 ).

124

T. Klijnsma and T. Steenvoorden

References 1. Achten, P., Koopman, P., Plasmeijer, R.: An introduction to task oriented programming. In: Zsók, V., Horváth, Z., Csató, L. (eds.) CEFP 2013. LNCS, vol. 8606, pp. 187–245. Springer, Cham (2015). https://doi.org/10.1007/978-3-319-15940-9_5 2. Brady, E.C.: Idris, a general-purpose dependently typed programming language: design and implementation. J. Funct. Program. 23(5), 552–593 (2013) 3. Koopman, P., Plasmeijer, R., Achten, P.: An executable and testable semantics for iTasks. In: Scholz, S.-B., Chitil, O. (eds.) IFL 2008. LNCS, vol. 5836, pp. 212–232. Springer, Heidelberg (2011). https://doi.org/10.1007/978-3-642-24452-0_12 4. Lijnse, B., Jansen, J.M., Plasmeijer, R.: Incidone: a task-oriented incident coordination tool. In: Proceedings of ISCRAM (2012) 5. McBride, C., Paterson, R.: Applicative programming with effects. J. Funct. Program. 18(1), 1–13 (2008) 6. Naus, N., Steenvoorden, T.: Generating next step hints for task oriented programs using symbolic execution. In: Byrski, A., Hughes, J. (eds.) TFP 2020. LNCS, vol. 12222, pp. 47–68. Springer, Cham (2020). https://doi.org/10.1007/978-3-03057761-2_3 7. Naus, N., Steenvoorden, T., Klinik, M.: A symbolic execution semantics for TopHat. In: Stutterheim, J., Chin, W. (eds.) IFL 2019: Implementation and Application of Functional Languages, Singapore, 25–27 September 2019, pp. 1:1–1:11. ACM (2019) 8. Pitts, A.M.: Operational semantics and program equivalence. In: Barthe, G., Dybjer, P., Pinto, L., Saraiva, J. (eds.) APPSEM 2000. LNCS, vol. 2395, pp. 378–412. Springer, Heidelberg (2002). https://doi.org/10.1007/3-540-45699-6_8 9. Plasmeijer, R., van Eekelen, M., van Groningen, J.: Clean language report version 2.1. Technical report. Radboud University, Nijmegen, The Netherlands (2002) 10. Plasmeijer, R., Lijnse, B., Michels, S., Achten, P., Koopman, P.W.M.: Taskoriented programming in a pure functional language. In: Schreye, D.D., Janssens, G., King, A. (eds.) Principles and Practice of Declarative Programming, PPDP 2012, Leuven, Belgium, 19–21 September 2012, pp. 195–206. ACM (2012) 11. Sewell, P.: Semantics of programming languages, computer science Tripos, part 1B (2017). Accessed 29 Nov 2019 12. Steenvoorden, T.: TopHat: task-oriented programming with style. Ph.D. thesis. Radboud University, Nijmegen, The Netherlands (2022) 13. Steenvoorden, T., Naus, N., Klinik, M.: TopHat: a formal foundation for taskoriented programming. In: Komendantskaya, E. (ed.) Proceedings of the 21st International Symposium on Principles and Practice of Programming Languages, PPDP 2019, Porto, Portugal, 7–9 October 2019, pp. 17:1–17:13. ACM (2019) 14. Stutterheim, J., Achten, P., Plasmeijer, R.: Maintaining separation of concerns through task oriented software development. In: Wang, M., Owens, S. (eds.) TFP 2017. LNCS, vol. 10788, pp. 19–38. Springer, Cham (2018). https://doi.org/10. 1007/978-3-319-89719-6_2

Semantic Equivalence of Task-Oriented Programs in TopHat

125

Open Access This chapter is licensed under the terms of the Creative Commons Attribution 4.0 International License (http://creativecommons.org/licenses/by/4.0/), which permits use, sharing, adaptation, distribution and reproduction in any medium or format, as long as you give appropriate credit to the original author(s) and the source, provide a link to the Creative Commons license and indicate if changes were made. The images or other third party material in this chapter are included in the chapter’s Creative Commons license, unless indicated otherwise in a credit line to the material. If material is not included in the chapter’s Creative Commons license and your intended use is not permitted by statutory regulation or exceeds the permitted use, you will need to obtain permission directly from the copyright holder.

Algorithm Design with the Selection Monad Johannes Hartmann(B) and Jeremy Gibbons Department of Computer Science, University of Oxford, Oxford, UK {johannes.hartmann,jeremy.gibbons}@cs.ox.ac.uk Abstract. The selection monad has proven useful for modelling exhaustive search algorithms. It is well studied in the area of game theory as an elegant way of expressing algorithms that calculate optimal plays for sequential games with perfect information; composition of moves is modeled as a ‘product’ of selection functions. This paper aims to expand the application of the selection monad to other classes of algorithms. The structure used to describe exhaustive search problems can easily be applied to greedy algorithms; with some changes to the product function, the behaviour of the selection monad can be changed from an exhaustive search behaviour to a greedy one. This enables an algorithm design framework in which the behaviour of the algorithm can be exchanged modularly by using different product functions. Keywords: Selection monad · Functional programming design · Greedy algorithms · Monads

1

· Algorithm

Introduction

In 2010 Mart´ın Escard´ o and Paulo Oliva first describe the selection monad in their paper Selection Functions, Bar Recursion, and Backward Induction [2], where they explain bar recursion in terms of sequential games. They use the selection monad to model bar recursion and further show how sequential games can be solved using the selection monad. In subsequent work [3], they relate the selection monad to several different applications, such as Double-Negation Shift in the field of logic and proof theory and the Tychonoff Theorem in the field of topology. Selecting a candidate for a solution out of a collection of options, based on some property function that tells us how good a candidate is, is a recurring pattern in computer science. We can use selection functions of type (A -> R) -> A to model this decision process. For example, when playing a sequential game, making a move means deciding for a particular move out of all possible moves. We can imagine a property function that somehow is able to compute how good each move is, and we then select the best one. The general idea when using selection functions is to model a problem in terms of a list of selection functions [(A -> R) -> A]. These selection functions then can be combined into a single selection function ([A] -> R) -> [A] with the help of the product c Springer Nature Switzerland AG 2022  W. Swierstra and N. Wu (Eds.): TFP 2022, LNCS 13401, pp. 126–143, 2022. https://doi.org/10.1007/978-3-031-21314-4_7

Algorithm Design with the Selection Monad

127

for selection functions. Further, it turns out that these selection functions form a monad, and that the product for selection functions is given by the monadic sequence function. The product for selection functions hereby models an exhaustive search algorithm, that tries out every possible solution to the problem and then selecting the overall best one. This can be applied to find optimal strategies for sequential games, where each individual selection function is modeling the choice of which move to play next, and the sequential composition of all this individual choices computes an optimal strategy for this game. In this paper we describe alternative product implementations for selection functions, providing different computational behaviour. With these different product implementations we are able to model both greedy algorithms and limited-lookahead search algorithms. This enables us to extend the application of selection functions to a wider range of problems. In Sect. 2 we introduce selection functions in greater detail and provide an intuition for and examples of the product for selection functions. Then in Sect. 3 we provide an alternative product implementation that models a greedy behaviour, and in Sect. 4 we will have a look at some examples of greedy algorithms that are modeled with the greedy product for selection functions. In Sect. 6 we introduce another variant of the product of selection function that limits the number of steps it looks into the future. We conclude this paper and discuss potential future work in Sect. 8.

2

Selection Functions

Selection functions summarise the process of selecting an individual object out of a collection of objects. Selecting something means making a choice, deciding in favour of one object over all the other objects of the collection we are choosing from. So in order to make a choice we need to be able to judge these objects, and based on that judgement, decide on a particular object out of a set of all possibilities. As an example, let’s say we want to buy a car. Therefore we have a collection of all available cars on the market, and we want to buy the best one. “Best” in this case depends heavily on our personal perspective on cars, so let’s define “best” as “fastest”. Then our personal selection process would be: look up top speeds of every car in our collection of all available cars, and select the one with the highest top speed. We can model this in the functional programming language Haskell as follows: myChoice :: Car myChoice = maxWith allCars topSpeed maxWith :: Ord b => [a] -> (a -> b) -> a topSpeed :: Car -> Int allCars :: [Car] Here, maxWith is the function that selects an element from the given list to maximise the given property function. Of course, top speed is not the only property

128

J. Hartmann and J. Gibbons

one might take into account when buying a car; maxWith abstracts from the property used for judging. Given elements of type A and properties of type R, a selection function takes a property function p :: A -> R for judging elements, and selects some element of type A that is optimal according to the given property function. A good example of such a selection function is the maxWith from above, partially applied to a collection of objects, or the complementary minWith function. Escard´ o and Oliva studied these selection functions and connected them to different research areas, including game theory and proof theory [3]. 2.1

Pairs of Selection Functions

As a next step, we want to look at how to combine two selection functions into a new bigger selection function. We will call this pairing of selection functions. To be consistent with the notation of previous literature we first define a type synonym for selection functions: type J r a = (a -> r) -> a One way of thinking of a function of type J R A is as embodying a nonempty collection of A elements: when told how to judge individual elements by R-values, the function can deliver an optimal element by that measure. Now we define the pair operator combining two selection functions to make a new one [3]: pair :: J r a -> J r b -> J r (a,b) pair f g p = (a,b) where a = f (\x -> p(x,g(\y -> p(x,y)))) b = g (\y -> p(a,y)) This new selection function selects (A,B) pairs, and therefore awaits a property function p :: (A,B) -> R that judges pairs. Suppose we are given such property function; then an (A,B) pair needs to be produced as output. To do so, we need to extract an A out of the first selection function f; we do so by constructing a new property function that judges As. However, we only have something that judges (A,B) pairs. The trick is, for every x :: A we would like to judge, we extract a corresponding y :: B out of the second selection function and the pair (x,y) with the given property function p. Once we have found our optimal a, we can use it in the same way to select a corresponding b out of the selection function g and returning both as a pair. So intuitively, this pair operator for selection functions combines two given selection functions in a way that the resulting selection function produces a pair that is optimal for a property function that judges these pairs. The two elements of this pair are extracted from the given selection functions in a way that they are always judged in the context of a full pair. From the perspective of selection functions as embodying a collection of objects, the pair for two selection functions embodies the cartesian product of the two collections.

Algorithm Design with the Selection Monad

2.2

129

Password Example

Let’s consider the following example, where we use the product of selection functions to crack a secret password. This secret password consists of two different tokens, a secret number between 1–9 and a secret character between a–z: type Password = (Int, Char) We are also given a property function that tells us if a given password is correct. We will treat this property function as a black box and not depend on its implementation details: p :: Password -> Bool p (a,b) = a == 7 && b == 'p' In order to crack this password we will now define two individual selection functions for the two different parts of the password: selectInt :: Ord r => J r Int selectInt p = maxWith p [1..9] selectChar :: Ord r => J r Char selectChar p = maxWith p ['a'..'z'] Note here that both selection functions require a property function p to judge Ints or Chars individually, which we don’t have at this stage. However, building the pair with the above defined pair function will result in a combined selection function that we can apply our property function to, and it indeed calculates the correct solution: > pair selectInt selectChar p ---> (7,'p') (Recall that Haskell defines False < True, so this expression returns a pair that satisfies p, provided that any of the given pairs does so). Because the only way to judge each individual component is by the given property function that judges complete password pairs, the pair function needs to create new property functions for its selection functions that are able to judge individual objects in a broader context. This example fits nicely with the intuition that each selection function already embodies a set of objects, and that the pair function builds the cartesian product of these two sets and judges each pair individually and returns an optimal pair. 2.3

Iterated Product of Selection Functions

The next logical step is to expand this from pairs to n-ary products. Unfortunately the standard Haskell type system is too restrictive to allow arbitrarily typed products of selection functions, and therefore the closest we can get is combining a list of selection functions for a common element type into a single selection function [3]:

130

J. Hartmann and J. Gibbons

product :: [J r a] -> J r [a] product [] _ = [] product (e : es) p = a : as where a = e(\x -> p(x : product es (p . (x:)))) as = product es (p . (a:)) This particular implementation of product behaves similarly to the previous pair function. Given a property function p :: [A] -> R that judges lists, this function iterates through the list of selection functions in order to extract a concrete object out of each of them and therefore building a property function that considers both all previous decisions and all future decisions. Note that for each recursive call a new property function is created that prepends the current choice of object via (p . (a:)). Further, to extract an object out of the current selection function e, a property function is created that judges each element of the underlying set in context of all possible future choices by recursively calling product within the property function. 2.4

Dependently Typed Version

Note that in this implementation we are not using the more restrictive type to our advantage. In contrast to the tuples of the above defined pair operator, the choice of lists comes with two drawbacks. First, the elements of the list must all be of the same type and second, lists in Haskell can be of arbitrary length while tuples are always of a fixed size. The above presented product implementation does not take advantage of the first drawback, and assumes the second restriction. It constructs property functions that always judge elements in context with previous choices and potential future choices, making the restriction that every element needs to be of the same type irrelevant and assumes that the given property function is able to judge lists of the exact same length as the given list of selection functions. In a dependently typed language we would be able to type this particular product implementation with a more expressive type that allows heterogeneous lists that can contain elements of any type and also ensures that each list we are dealing with has the correct length. An example implementation with this more expressive type in the programming language Idris is not difficult to construct [6]. We will later use this less expressive type to our advantage when we have a look at greedy algorithms, where we omit lookahead into the future. 2.5

Extended Password Example

The previous password example can now easily be extended to make use of the product function. We now want to crack a password of type String. We know only that it contains characters from a–z and that its length is 8: type Password = String

Algorithm Design with the Selection Monad

131

and we are given a property function as a black box again: p :: String -> Bool p x@[_,_,_,_,_,_,_,_] = x == "password" p _ = undefined Note that our property function is only defined for Strings of length 8. We can now utilise the previous selectChar and construct a list that contains this selection function exactly 8 times, once for each character of the password: es :: Ord r => [J r Char] es = replicate 8 selectChar Utilising the previous product function will calculate the correct solution: > product es p ---> "password" This example again fits nicely the previously described intuition, where each component of the solution can only be judged in context with the previous selections as well as all possible future selections. This is underlined by the fact that by design, the property function is only able to judge solutions of the correct length, while being undefined for inputs of different lengths. The absence of any exceptions strengthens our intuition, that individual objects are always judged in full context. 2.6

Selection Functions Form a Monad

Further, Escard´ o and Oliva show [3] that the type of selection functions forms a strong monad. This strengthens the intuition that a selection function embodies a collection of elements. Selection functions with a fixed property type can be made instance of the monad class with this bind function: (>>=) :: J r a -> (a -> J r b) -> J r b (>>=) e f = \p -> f (e (p . flip f p)) p and this return function: return :: a -> J r a return a = \_ -> a Intuitively, the monad instance takes care that the underlying set elements are always judged in context. For e >>= f, we first want to extract an object of type A out of the given e, and then use f to transform it into a J R B. To extract an object of type A out of e we need to build a new property function that judges each element by how well it produces objects of type b by incorporating f into the new property function. With J R A being a monad, we can utilise the prelude’s monad functions to our advantage. Escard´ o and Oliva claim [3] that their product implementation is equivalent to the monadic sequence function from the Haskell prelude. A proof of this is claim is given in the appendix.

132

2.7

J. Hartmann and J. Gibbons

History-Dependent Product of Selection Functions

As an extension to the previous product for selection functions, Escard´ o and Oliva introduced [3] a history-dependent version of the product function. It keeps track of previous decisions and allows therefore for more dynamic selection functions, that can base the set from which they select on previous decisions: hProduct hProduct hProduct where

:: [a] -> [[a] -> J r a] -> J r [a] h [] p = [] h (e : es) p = a : as a = (e h) (\x -> p(x : hProduct (h++[x]) es (p . (x:)))) as = hProduct (h++[a]) es (p . (a:))

This history-dependent version proves useful when modeling sequential games, in which the moves available at a given stage of the game typically depend on the previously played moves. The application of the selection monad to sequential games is already widely studied [1–4,7,8]. 2.8

Efficiency Drawbacks of This Implementation

The pair, product, and hProduct implementations introduced above combine individual selection functions into a single product selection function. In order to extract the necessary elements out of the individual selection functions, the required property functions are created so as to always consider previous choices as well as potential future choices. This models an exhaustive search behaviour, where every possible combination of objects is judged by a given property function and the overall favorable combination of objects is then chosen. This results in an exponential runtime with respect to the number of selection functions to be combined. This exponential runtime makes the application of the selection monad infeasible for computing solutions for many problems. Several methods to cope with this runtime have been explored in the past. There are several approaches to avoid unnecessary computations. Firstly, the individual selection functions can be neatly constructed such that they prune the inspection of their elements in unnecessary cases. Concretely, if the cost value R by which each object is judged has an upper or lower bound, the search can be stopped once a solution reaches one of these bounds [5]. Additionally, one should try to make as little as possible use of the property function, as it always constructs all possible future objects to judge the current element in context. One concrete example for this can be to avoid the use of the property function completely if there is only one element to choose from. Secondly, the different product variants themselves leave room for improvement. For example, for minimax algorithms [3], a different hProduct function could implement alpha-beta pruning, which is used in game theory to reduce the search space when calculating perfect plays for sequential games. One final flaw with the current implementations of product and hProduct variants is that they perform a lot of redundant calculations. In particular, each starts with the first selection function in the list and calculates all possible

Algorithm Design with the Selection Monad

133

future choices for each internal element. Based on this information it chooses the object that leads to the best possible future outcome. However, continuing the recursion, it forgets that it already explored all possibilities from there and starts the computation all over again, leading to a lot of redundant computations. For the remainder of this paper, we will focus on different implementations for the iterated product of selection functions, which will have different computational behaviour. This will enable us to efficiently express greedy algorithms and other algorithm classes as products of selection function, and further enable us to abstract the behaviour of the different algorithms into the product functions.

3

Greedy Algorithms

The product of selection functions as we have seen so far models an exhaustive search algorithm, exploring all possible combination of objects in the underlying sets of the selection functions. In this section, we explore a different product for selection functions. By using the less expressive type of the product function to our advantage, we are able to modify it in a way that allows us to model a greedy algorithm behaviour as a product of selection functions. We previously identified that by choosing lists as container type for the selection functions, we restrict all the selection functions in the container to be of the same type. Further, the Haskell list type does not impose any length restrictions on the input list of property function. In order to model a greedy behaviour we can use this more general type to our advantage. Once we require the property function to be defined for partial solutions as well, we can define a new greedyProduct function that judges the underlying objects of the selection functions only in the context of previous choices without caring about potential future choices: greedyProduct greedyProduct greedyProduct where a = as =

:: [J r a] -> J r [a] [] p = [] (e : es) p = a : as e (\x -> p [x]) greedyProduct es (p . (a:))

So iterating through the list of selection functions, we extract a value out of each selection function by building a new property function of type A -> R by converting each A into a singleton list and then applying our property function to it. In the recursive call we build a new property function that keeps track of the previous choices. Note that this definition differs from the product definition by omitting the recursive call inside the new property function that calculates all possible future choices. Further, we can also define the corresponding historydependent version: greedyHProduct :: [a] -> [[a] -> J r a] -> J r [a] greedyHProduct h [] p = [] greedyHProduct h (e : es) p = a : as

134

J. Hartmann and J. Gibbons

where a = (e h) (\x -> p [x]) as = greedyHProduct (h++[a]) es (p . (a:)) Judging the individual objects outside of a global context locally captures the essence of greedy algorithms. In general, algorithms are called “greedy” when they perform only a local optimisation at each step. We can now utilise these new products for selection functions to implement greedy algorithms. The general idea is to define each individual local step of the greedy algorithm as a selection function; then the greedy algorithm arises from building the greedy product of these selection functions. The greedy behaviour is thereby abstracted away into the product function, enabling us to describe greedy algorithms in a similar way form to exhaustive search algorithms.

4

Examples of Greedy Algorithms

In this section we look at some examples applying the new greedyProduct variants to solve problems in a greedy way. 4.1

Password Example

To continue the password example from above in a greedy way, we require a more sophisticated property function, that is able to judge partial solutions. So we are now given the following property function, that returns the number of correctly guessed characters of the password, instead of simply a boolean: p :: String -> Int p = length . filter id . zipWith (==) "password" We can reuse our previously defined selection functions: es :: Ord r => [J r Char] es = replicate 8 selectChar And with the new property function we can utilise the greedyProduct which will calculate the correct solution: > greedyProduct es p --> "password" With the new property function, we don’t need to know all future possibilities when determining the correct character at the next position of the password. We just need to be aware of our previous choices and can then decide greedily for the character that maximises the property function.

Algorithm Design with the Selection Monad

4.2

135

Prim’s Algorithm

A textbook example for a greedy algorithm is Prim’s algorithm for finding a minimal spanning tree for a graph [9]. Given a weighted, undirected graph, a minimal spanning tree is a subset of edges of the given graph that forms a tree and is minimal in its weight. The general idea of Prim’s algorithm is to grow a tree by repeatedly greedily selecting the lightest edge extending the tree (that is, the lightest edge connected to the tree and not creating a cycle). To implement Prim’s algorithm in terms of selection functions, we first define a graph as a list of edges, and an edge as a triple of integers with the first two elements being the two nodes of the edge and the third element the weight of the edge: type type type type

Node Weight Edge Graph

= = = =

Int Int (Node, Node, Weight) [Edge]

Further, we then define a helper function that calculates if a node is part of a given graph: nodeOf :: Graph -> Node -> Bool nodeOf [] _ = False nodeOf ((x,y,_):xs) n = n == x || n == y || nodeOf xs n We can then define a function calculating the total weight of a given collection of edges: p :: [Edge] -> Weight p [] = 0 p ((_,_,x):xs) = x + p xs Further, we then need a function that calculates all edges that can be added to the tree such that it stays a tree. An edge of the graph is a candidate exactly if one of its ends is already in the tree, and the other is not. In the initial case, where there is no tree existing, all edges are potential candidates. getCandidates getCandidates getCandidates where f (x,y,_)

:: Graph -> [Edge] -> [Edge] g [] = g g h = filter f g = nodeOf h x && not (nodeOf h y) || not (nodeOf h x) && nodeOf h y

To build our list of selection functions for a given graph, we want to select the lightest edge of all possible edges according to our property function. We also need exactly one fewer selection functions than there are nodes in the original graph. (where nodes is a function that returns all nodes of a given graph)

136

J. Hartmann and J. Gibbons

selectEdge :: Ord r => Graph -> [[Edge] -> J r Edge] selectEdge g = replicate (length (nodes g) - 1) f where f x p = minWith p (getCandidates g x) Now considering the following example graph: exampleGraph :: Graph exampleGraph = [(1,2,1),(2,3,5),(2,4,9),(4,5,20),(3,5,1)] we can form the product of our selection functions with the greedyProduct function. Applying this to our property function we get a minimal spanning tree as a result: greedyHProduct [] (selectEdge exampleGraph) p ---> [(1,2,1),(2,3,5),(3,5,1),(2,4,9)] 4.3

Greedy Graph Walking

In this example, we are given a directed weighted graph and a start node, and we want to walk a given number of steps in the graph and thereby minimise the cost of the path we walked. This example will illustrate the different computational behaviour of the greedy product in comparison to the normal product. We use the same representation of graphs and edges from the previous example, together with the property function that can also be used to calculate the cost of a path. We now define an getCandidates function that, given a graph and the path we already walked, calculates the possible next edges: getCandidates getCandidates getCandidates getCandidates

:: Graph -> g [] g [(_,n,_)] g (_:xs)

[Edge] -> [Edge] = undefined = filter (\(x,_,_) -> x == n) g = getCandidates g xs

Note, that the getCandidates function is undefined for the empty path. Therefore we are required to have the start edge in the initial path. Next, we define the list of selection functions. Each selection function takes a history describing the path that was already walked on the graph, calculating all possible next edges and selecting the edge with the minimum cost according to the property function. For our example, we want to walk 3 steps on the graph and therefore replicate the selection function 3 times. es :: Ord r => Graph -> [[Edge] -> J r Edge] es g = replicate 3 f where f x p = minWith p (getCandidates g x) Now consider the following graph, as shown in Fig. 1. exampleGraph :: Graph exampleGraph = [(1,2,1),(2,3,1),(2,4,5),(3,5,10), (4,6,1), (6,7,2), (5,7,1)]

Algorithm Design with the Selection Monad

137

Fig. 1. Example graph

To greedily walk a path on this graph, we can utilise the greedyHProduct with the initial edge (1,2,1) in the history and our selection functions applied to the exampleGraph. However, applying a greedy algorithm on this graph, locally choosing the best available edge at each stage, we won’t get an optimal result: greedyHProduct [(1,2,1)] (es exampleGraph) p ---> [(1,2,1),(2,3,1),(3,4,10),(5,7,1)] In contrast, when using the normal product, we do achieve the optimal result (total cost 9 rather than 13): hProduct [(1,2,1)] (es exampleGraph) p ---> [(1,2,1),(2,4,5),(4,6,1),(6,7,2)] This example illustrates that for a graph walking algorithm to work, some insight about future edges is needed in order to calculate the correct result. When choosing the edge going out of node 2, we need to look further than the current edge to detect that going from node 2 to node 3 would lead to an overall worse outcome. That is, a greedy algorithm doesn’t work.

5

Correctness

While the idea of a greedy algorithm is easy to grasp, proving that a greedy algorithm solves a given problem turns out to be quite difficult. If we view greedy algorithms in terms of selection functions, we can state that a greedy algorithm works if both the greedyProduct and the product functions calculate a result with the same cost: p (greedyProduct selectFunc p) = p (product selectFunc p)

(1)

and further with the history dependent version with a initial history h0: p (greedyHProduct h0 selectFunc p) = p (hProduct h0 selectFunc p) (2)

138

J. Hartmann and J. Gibbons

In the case of the graph walking example, we can construct a counter example for which this property does not hold: p (greedyHProduct [(1,2,1)] (es exampleGraph) p) == p (hProduct [(1,2,1)] (es exampleGraph) p) ---> False p (hProduct [(1,2,1)] (es exampleGraph) p) ---> 9 p (greedyHProduct [(1,2,1)] (es exampleGraph) p) ---> 13

6

Limited Lookahead

While greedy algorithms base their decision on the currently available options without considering the future, there are use cases where a limited lookahead into the future improves the result of an algorithm, without needing to go as far as exhaustive search. We can alter the product further to represent such a limited lookahead. To do so we introduce a limiting parameter to the product and distinguish the behaviour of the product depending on whether we reached the maximum lookahead depth. When building the property function for judging the individual underlying elements of a selection function, we decrement the lookahead depth in the recursive call. limProduct :: Int -> [J r a] -> J r [a] limProduct i [] p = [] limProduct i (e:es) p | i > 0 = a : as | i p [x]) Further, we can also introduce a history-dependent version with limited lookahead: limHProduct :: Int -> [a] -> [[a] -> J r a] -> J r [a] limHProduct i h [] p = [] limHProduct i h (e:es) p | i > 0 = a : as | i p [x])

Algorithm Design with the Selection Monad

6.1

139

Graph Example

Recalling the greedy graph walking example from Sect. 4.3, the greedy algorithm is not able to produce optimal results for the given example graph in Fig. 1. The greedy algorithm is limited to local decisions, and therefore unable to detect an upcoming costly edge. Utilising the limited lookahead product limHProduct now with a lookahead of 1, we are able to detect the upcoming costly edge 3 → 5 and are able to calculate an optimal solution (total cost of 9) for this example graph: shortestPathLimited = limHProduct 1 [(1,2,1)] (es exampleGraph) p ---> [(1,2,1),(2,4,5),(4,6,1),(6,7,2)] This might work for this particular graph, there is no guaranty that limited lookahead can be utilised for arbitrary complex graphs. However, considering graphs where every split eventually converges again into a single node after at most n steps, a n-step lookahead is sufficient for our graph walking example. Such graph might look similar to Fig. 2.

Fig. 2. Example limited lookahead graph

This example shows that having deeper insights about your problem can enable programmers to utilise limited lookahead algorithms. Another possible application of limited lookahead algorithms can be in the area of game theory. When, for example, implementing an AI opponent for chess, it is not computationally feasible to calculate a perfect game of chess. At some point, there needs to be a cutoff of the search space to ensure reasonably runtimes.

7

Iterated Product

A common pattern we can observe in the previous examples, is that we are always building a list of a fixed length containing copies of the same selection functions. Although not presented in this paper, there are applications for building products of different selection functions, one being the minimax algorithm for calculating solutions to sequential games. However, another different product for selection functions can be an iterated product where we iterate a given

140

J. Hartmann and J. Gibbons

single selection function until we arrive at the desired result. Therefore given a predicate pred :: [x] -> Bool that tells us whether a given history is a final solution to our problem, we extract objects out of the given selection function until the predicate is satisfied: iterate :: ([a] -> Bool) -> [a] -> J r a -> J r [a] iterate pred h e p | not (pred h) = a : as | otherwise = [] where a = e (\x -> p(x : iterate pred (h++[x]) e (p . (x:)))) as = iterate pred (h++[a]) e (p . (a:)) Further, we can also define iterated product for the history dependent product versions as well as the greedy product versions: hIterate :: ([a] -> Bool) -> [a] -> ([a] hIterate pred h e p | not (pred h) = a : | otherwise = [] where a = (e h) (\x -> p(x : hIterate as = hIterate pred (h++[a]) e (p

-> J r a) -> J r [a] as

pred (h++[x]) e (p . (x:)))) . (a:))

greedyIterate :: ([a] -> Bool) -> [a] -> J r a -> J r [a] greedyIterate pred h e p | not (pred h) = a : as | otherwise = [] where a = e (\x -> p [x]) as = greedyIterate pred (h++[a]) e (p . (a:)) greedyHIterate :: ([a] -> Bool) -> [a] -> ([a] -> J r a) -> J r [a] greedyHIterate pred h e p | not (pred h) = a : as | otherwise = [] where a = (e h) (\x -> p [x]) as = greedyHIterate pred (h++[a]) e (p . (a:))

We can easily utilise this new iterated products to work with our previous examples: 7.1

Examples

Password Example. Given that we already know that our password has a length of 8 characters, we can use the iterated product with a simple predicate that checks weather a given history reached the length of 8. Our selection function then is simply just the selectChar selection function: iterate (\x -> length x == 8) [] selectChar p ---> "password" greedyIterate (\x -> length x == 8) [] selectChar p ---> "password"

Algorithm Design with the Selection Monad

141

Graph Walking. Similar, we can utilise the iterated history dependent product to solve our graph walking problem. We first need to define our single selection function and then obtain the result by utilising the iterated product: selectionFunction :: [Edge] -> J r Edge selectionFunction x p = minWith p (getCandidates exampleGraph x) hIterate (\h -> length h == 3) [(1,2,1)] selectionFunction p ---> [(1,2,1),(2,4,5),(4,6,1),(6,7,2)] greedyHIterate (\h -> length h == 3) [(1,2,1)] selectionFunction p ---> [(1,2,1),(2,3,1),(3,4,10),(5,7,1)]

Prims Algorithm. And similar to the graph walking, we can implement Prims algorithm with the iterated product: selectionFunction :: [Edge] -> J r Edge selectionFunction x p = minWith p (getCandidates exampleGraph x) pred :: [Edge] -> Bool pred h = length h == (length exampleGraph - 1) greedyHIterate pred [] selectionFunction p ---> [(1,2,1),(2,3,5),(3,5,1),(2,4,9)] While these examples are straightforward, the true strength of the iterated product is that it can deal with solutions of different lengths. One example of this can be sequential games. There are many sequential games that have a specific ending condition rather than ending after a certain set of moves. This iterated product has the advantage of being able to deal with solutions of different lengths, while loosing the flexibility of building the product of a list of different selection functions. To solve this shortcoming we can build a iterated product that can deal with infinite lists: infIterate :: ([a] -> Bool) -> [a] -> [J r a] -> J r [a] infIterate pred h [] p = [] infIterate pred h (e:es) p | not (pred h) = a : as | otherwise = [] where a = e (\x -> p(x : infIterate pred (h++[x]) es (p . (x:)))) as = infIterate pred (h++[a]) es (p . (a:))

One can easily adapt this product to also work history dependent or in a greedy way.

8

Conclusion and Future Work

We have seen that in addition to the already known monadic product for selection functions, there are other product implementations for selection functions,

142

J. Hartmann and J. Gibbons

each capturing different computational behaviour. In particular with the above presented variations, the idea of describing problems as collections of selection functions can now be applied to problems that are solvable by greedy algorithms and limited lookahead algorithms. Moreover, the computational behaviour of an algorithm can be changed modularly by using different products for selection functions, while the problem description stays the same. In addition to greedy products and limited lookahead products, there is the potential for more product implementations that behave differently. One example for this might be a product that is able to perform Alpha-Beta pruning minimax algorithms.

Appendix A

Proof that Product Equals Sequence

xm >>= \x -> sequence' xms >>= \xs -> return (x : xs) -- {{ expand >>= definition }} xm >>= \x -> (\p -> (\xs -> return (x : xs)) ((sequence' xms) (p . flip (\xs -> return (x : xs)) p)) p) -- {{ apply lambda }} xm >>= \x -> (\p -> (return (x : ((sequence' xms) (p . flip (\xs -> return (x : xs)) p))) ) p) -- {{ resolve return }} xm >>= \x -> (\p -> (x : ((sequence' xms) (p . flip (\xs -> return (x : xs)) p)))) -- {{ apply flip }} xm >>= \x -> (\p -> (x : ((sequence' xms) (p . (\_ xs -> (x : xs)) p)))) -- {{ apply lambda }} xm >>= \x -> (\p -> (x : ((sequence' xms) (p . (\xs -> (x : xs))))) ) -- {{ resolve function composition }} xm >>= \x p -> (x : ((sequence' xms) (\xs -> p (x : xs)))) -- {{expand >>= definition }} \p' -> ( (\x p -> (x : ((sequence' xms) (\xs -> p (x : xs))))) ((xm) (p' . flip (\x p -> (x : ((sequence' xms) (\xs -> p (x : xs))))) p')) p' ) -- {{ apply lambda }} \p' -> ( (\x p -> (x : ((sequence' xms) (\xs -> p (x : xs))))) (xm (\x -> p' (x : ((sequence' xms) (\xs -> p' (x : xs)))))) p' ) -- {{ apply lambda }} \p' -> (\x -> (x : ((sequence' xms) (\xs -> p' (x : xs))))) (xm (\x -> p' (x : ((sequence' xms) (\xs -> p' (x : xs)))))) -- {{ rewrite with where }} \p' -> z : sequence' xms (\xs -> p' (z : xs)) where z = xm (\x -> p' (x : ((sequence' xms) (\xs -> p' (x : xs))))) -- {{ rewrite with where }} \p' -> z : zs where z = xm (\x -> p' (x : sequence' xms (p' . (x:)))) zs = sequence' xms (p' . (z:))

Algorithm Design with the Selection Monad

143

References 1. Bolt, J., Hedges, J., Zahn, P.: Sequential games and nondeterministic selection functions. arXiv preprint arXiv:1811.06810 (2018) 2. Escard´ o, M., Oliva, P.: Selection functions, bar recursion and backward induction. Math. Struct. Comput. Sci. 20(2), 127–168 (2010) 3. Escard´ o, M., Oliva, P.: What sequential games, the Tychonoff theorem and the double-negation shift have in common. In: Proceedings of the Third ACM SIGPLAN Workshop on Mathematically Structured Functional Programming, pp. 21– 32 (2010) 4. Escard´ o, M., Oliva, P.: Sequential games and optimal strategies. Proc. R. Soc. A: Math. Phys. Eng. Sci. 467(2130), 1519–1545 (2011) 5. Hartmann, J.: Finding optimal strategies in sequential games with the novel selection monad. arXiv preprint arXiv:2105.12514 (2018) 6. Hartmann, J.: Dependently typed selection monad (2022). https://github.com/ IncredibleHannes/DependentlyTypedSelectionMonad 7. Hedges, J.: The selection monad as a CPS transformation. arXiv preprint arXiv:1503.06061 (2015) 8. Hedges, J.M.: Towards compositional game theory. Ph.D. thesis, Queen Mary University of London (2016) 9. Prim, R.C.: Shortest connection networks and some generalizations. Bell Syst. Tech. J. 36(6), 1389–1401 (1957)

Sound and Complete Type Inference for Closed Effect Rows Kazuki Ikemori1(B) , Youyou Cong1 , Hidehiko Masuhara1 , and Daan Leijen2 1 Tokyo Institute of Technology, Tokyo, Japan [email protected], [email protected], [email protected] 2 Microsoft Research, Seattle, USA [email protected]

Abstract. Koka is a functional programming language that has algebraic effect handlers and a row-based effect system. The row-based effect system infers types by naively applying the Hindley-Milner type inference. However, it infers effect-polymorphic types for many functions, which are hard to read by the programmers and have a negative runtime performance impact to the evidence-passing translation. In order to improve readability and runtime efficiency, we aim to infer closed effect rows when possible, and open those closed effect rows automatically at instantiation to avoid loss of typability. This paper gives a type inference algorithm with the open and close mechanisms. In this paper, we define a type inference algorithm with the open and close constructs.

Keywords: Algebraic effects and handlers

1

· Type inference

Introduction

Koka [12,21] is a functional programming language that has algebraic effects and handlers [7,15] which are a recently introduced abstraction of computational effects. An important aspect1 of Koka is that it tracks the (side) effects of functions in their type. For example, the following function: fun sqr( x : int ) :   int x * x

has an empty effect row type   as it has no side effect at all. In contrast, a function like: fun head( xs : lista ) : exn a match xs Cons(x,xx) → x

1

Koka is the first practical language that tracks the (side) effects of functions in their type. .

c Springer Nature Switzerland AG 2022  W. Swierstra and N. Wu (Eds.): TFP 2022, LNCS 13401, pp. 144–168, 2022. https://doi.org/10.1007/978-3-031-21314-4_8

Sound and Complete Type Inference for Closed Effect Rows

145

gets the exn effect row type as it may raise an exception at runtime (if we pass an empty list). To track effects, Koka uses row-based effect types [9]. These are quite suitable to combine with standard Hindley-Milner type inference as row equality has decidable unification (unlike subtyping for example). When doing straightforward Hindley-Milner type inference with row-based effect types many functions will be polymorphic in the tail of the effect row (we call such effect rows open). For example, the naively inferred types of the previous two functions would be: fun sqr : int → e int fun head: lista → exn|e a

Observe that both types are polymorphic in the effect tail as effect variable e. These are in a way the natural types, signifying for example that we can use sqr in any effectful context. However, in practice, we prefer closed types instead, as these are easier to explain, and easier to read and write without needing to always consider the polymorphic tail. Moreover, it is possible to generate more efficient code for functions with closed effect rows. When executing an effect operation (which is similar to raising an exception), there is generally a dynamic search at runtime for a corresponding handler of that effect. This can be expensive, and Koka uses evidence passing [20, 21] to pass handler information as a vector at runtime. When an effect row is closed, the runtime shape of the vector is statically determined, and instead of searching for a handler, we can select the right handler at a fixed offset in the vector. This can be much more efficient. Therefore, the type inference algorithm in the current Koka compiler generally infers closed effect rows for function bindings, where it relies on two mechanisms, open and close, for converting between open and closed function types. However, the opening and closing features of the inference algorithm have never been formalized. In this paper, we make the following contributions: – We formalize type inference with the open and close mechanisms precisely (Sect. 4). – We prove that the type inference algorithm is sound and complete (Sect. 5) and infers most general types. First, we give an introduction to algebraic effects and handlers, and explain what kind of types we would like to infer (Sect. 2). Next, we present an implicitly typed calculus with algebraic effects and handlers, and give a set of declarative type inference rules (Sect. 3). Then, we define syntax-directed type inference rules and show they are sound and complete with respect to the declarative rules (Sect. 4). We also define a type inference algorithm and prove its soundness and completeness (Sect. 5). Lastly, we discuss related work (Sect. 6) and conclude with future directions (Sect. 7).

146

2

K. Ikemori et al.

Motivation

2.1

Algebraic Effects and Handlers

Algebraic effects and handlers [7,15] are a uniform mechanism for representing computational effects. When programming with algebraic effects and handlers, the user first declares an effect with a series of operations. They then define a handler that specifies the meaning of operations, using the continuation (resume in Koka) surrounding the operations. As an example, let us implement a reader effect in Koka. effect read2 ask-int() : int ask-bool(): bool

The reader effect read2 has two operations ask-int and ask-bool, which take no argument and return an integer and a boolean, respectively. Below is a program that uses the two operations. handler { ask-int() { resume(12) } ask-bool() { resume(True) } } { if ask-bool() then ask-int() else 42 }

In the above program, the conditional expression is surrounded by a handler that specifies the meaning of ask-int and ask-bool. The evaluation goes as follows. First, ask-bool() is interpreted by the second clause of the handler, which says: continue (resume) evaluation with the value True. Second, the conditional expression reduces to the then -clause. Third, ask-int() is interpreted by the first clause of the handler, which says: continue evaluation with the value 12. Thus, the program evaluates to 12. 2.2

Naive Hindley-Milner Type Inference with Algebraic Effects and Handlers

Koka employs a row-based effect system similar to a record type system [9]. It is also equipped with polymorphic type inference, which is similar to the HindleyMilner type inference but has an additional mechanism for manipulating effects. It turns out that the types inferred by a natural extension of the HindleyMilner inference are not suitable for evidence passing [20,21]. As an example, consider the function f below. fun f(x) ask-int() x

This function f is inferred to have the type foralla,e1  a → read2 |e1  a. The type contains an effect variable e1 , representing the effects of the function

Sound and Complete Type Inference for Closed Effect Rows

147

body. Now, suppose we have the following user program (where head may raise an exception). if True then f(1) else head([1])

As the two branches must have the same effect row, read2 |e1  and exn|e2  are unified to exn,read2 |e3  or read2 ,exn|e3 , where e3 is a fresh effect variable. In the former case, the Koka compiler has to dynamically search for the read2 handler because the effect row of f has been changed from read2 |e2  to exn,read2 |e3 . In the latter case, the Koka compiler would dynamically search for the exn handler because the effect row of head has been changed from exn|e2  to read2 ,exn|e3 . To avoid dynamic search, we would like to infer the type foralla a → read2  a for f. We call this function type closed, in the sense that we cannot grow the effect row by instantiating the effect variable. When f is given this closed function type, we know that f only performs the read2 effect. Therefore, we can obtain a corresponding handler in constant time (see [20,21] for more details). Intuitively, higher order functions such as map must have a effect variable and need to dynamically search for the corresponding handler because the Koka compiler cannot statically determine the effects of function body at runtime. The other functions such as f should not have a effect variable in order to generate the efficient code by evidence passing, because the set of effects of function body can be statically determined. The Hindley-Milner type inference also occasionally yields type signatures that are more general than what the user may expect. Consider the identity function. fun id( x ) x

It is likely that the user defines id as a function of the following type. fun id( x : a ):   a x

Notice that the effect of function body is an empty row  . This is because the body of id is variable, which has no effect. However, based on the HindleyMilner inference, id is given type foralla,e a → e a. Here, e is an effect variable representing any effects. When the user is shown this type, they might be surprised because they did not introduce the effect variable e. In contrast, the type foralla a →   a, which has a total effect  , seems more natural. We might take other approaches to show the precise type signatures, but we believe our approach is useful for users. For example, we could treat all effect rows as open ones by implicitly inserting an effect variable. This approach is adopted in the Frank language; we will provide more details in Sect. 6. 2.3

Type Inference with open and close

In order to optimize more functions and display precise types, the Koka compiler manipulates effect types using special mechanisms open and close. In this paper,

148

K. Ikemori et al.

we formalize type inference with these mechanisms. The key principle is to close the effect row of all named functions (bound by the let/val expression), and open the effect row when we encounter variables of a closed function type. Let us illustrate how open and close work through an example. val id = fn(x) x id( ask-int() )

In the type system described in this paper, the function fn(x) x is given the most general type foralla,e a → e a. When the function is bound to id, the type is closed to foralla a →   a. This ensures that, when the type of a named function is displayed to the user, it must be of the closed form. When id is instantiated in the body, the type is opened again, first to foralla,e a → e a and then unified to int → read2  int so that id( ask-int() ) is well-typed in its context. In general, open introduces a fresh effect variable e into a closed effect row l1 ,...,ln , yielding l1 ,...,ln |e. Dually, close removes an effect variable from an open effect row. Together these allow us to avoid dynamic handler search and display simplified type signatures. The reader may find opening and closing similar to subtyping. We use these mechanisms to avoid complex constraints over the subtyping relation and undecidability of the unification algorithm. In the following sections, we will show simple type inference for open and close.

3

Implicitly Typed Calculus for Algebraic Effects and Handlers

In this section, we present ImpKoka, an implicitly typed surface language that has algebraic effects and handlers, as well as polymorphism and higher-order kinds à la System Fω. The structure of this section is as follows. First, we define the syntax of kinds, types, and effect rows (Sects. 3.1.1–3.1.3). Next, we define the syntax of expressions (Sect. 3.1.4). Lastly, we give a set of declarative type inference rules (Sect. 3.2). Note that we do not define an operational semantics for ImpKoka, but instead define a translation from ImpKoka to an extension of System F [20]. The definition can be found in the appendix. 3.1

Syntax

3.1.1 Kinds and Types We define the syntax of kinds and types of ImpKoka in Fig. 1. Similar to System F [20], we have kinds for value types (∗), type constructors (k → k ), effect rows (eff), and effect labels (lab). Differently from System F , we distinguish between monotypes and type schemes. Monotypes consist of type variables αk , type constructors c k τ . . .τ , and function types τ →  τ . In particular, sk is a special type constructors used in a unification function for operations, and the detail of sk will be explained in Sect. 5.2. Note that type

Sound and Complete Type Inference for Closed Effect Rows

149

Fig. 1. Types, kinds, effect signatures, and effect rows of ImpKoka

variables and constructors have a kind annotation k . The effect  in a function type τ →  τ represents the effect performed by the function body. We will use α and β for value type variables and μ for effect type variables, often without kind annotations. 3.1.2 Signatures We define operations op, effect signatures sig, and sets of effect signatures Σ again as in System F (Fig. 1). An effect signature is a set of pairs of an operation name and a type. A set of effect signatures associates each effect label li with a corresponding effect signature sig i . We assume that operation names and effect labels are all unique, and that Σ is defined at the top level. 3.1.3 Effect Rows In ImpKoka, we use effect rows [5,11] to represent a collection of effects to be performed by an expression. As in System F , an effect row is either an empty row  , or an effect variable μ, or an extension l |  of an effect row  with an effect label l , respectively. For example, assuming exn is an effect label representing exceptions, exn, read2  is an effect row representing a collection of exception and reader effects. The kinds of   and l are eff (representing an effect type) and lab (representing an effect label), respectively. The kind of _ | _ is lab → eff → eff. We will use   and _ | _ without kind annotations. The equivalence relation for effect rows is also defined in the same way as in System F (Fig. 2). The [refl] and [eq-trans] rules are the reflexivity and transitivity rules. The [eq-swap] rule says that two effect labels l1 and l2 can be swapped if they are distinct. The [eq-head] rule tells us that two effect rows are equivalent when their heads and tails are equivalent. Note that effect rows can have multiple occurrences of the same effect label. For example, we may have exn and exn, exn, and they are treated as different effect rows. The advantage of this design is that we can define type inference rules in a simple manner, by using only type equivalence.

150

K. Ikemori et al.

Fig. 2. Equivalence of row types

Fig. 3. Expressions of ImpKoka

3.1.4 Expressions We define the syntax of expressions of ImpKoka in Fig. 3. In addition to the standard lambda terms, we have let-binding let x = v in e, handlers handler h and operation performing perform op. Note that we treat both handler h and perform op as a value. If we want to handle an expression e with handler h, we write handler h (λ_.e) via application. Similarly, if we want to perform an operation op with argument e, we write perform op e via application. 3.2

Declarative Type Inference Rules

We now turn to the declarative type inference rules with open and close (Fig. 4). Here, we use a typing judgment of the form Ξ | Γ | Δ  e : σ | . The judgment states that, under type variable context Ξ and typing context Γ and Δ, expression e has type σ and performs effect . Among the two contexts, Δ is a type assignment for named (i.e., let -bound) functions, which inhabit a function type with a closed effect row (we will call such types closed function types). The other context Γ is a type assignment for all other variables. The [var] and [varopen] rules in Fig. 4 are interesting. The [var] rule concludes with a type σ that comes from either Δ or Γ , and an arbitrary effect . Note that, although a variable does not perform any effects, we cannot replace  by  , because we do not have subeffecting rules in ImpKoka. Note also that the rule applies only to variables whose type is not a closed function type. The [varopen] rule derives an open function type for a variable of a closed function type. The variable must reside in Δ, because we can only open the type of named functions. As an example, we can use [varopen] to make id (perform ask -int ()) well-typed, because we can derive Ξ | Γ | Δ  id : int → read2  int by [varopen] and Ξ | Γ | Δ  perform ask -int () : int | read2  by [perform] and [app].

Sound and Complete Type Inference for Closed Effect Rows

151

Fig. 4. Declarative type inference rules

Other type rules in Fig. 4 are standard. The [lam] rule derives a function type for a lambda abstraction. Observe that the effect  of the body e is integrated into the type τ1 →  τ2 in the conclusion. Note that the lambda-bound variable x is added to Γ , not Δ. Therefore, in the derivation of the body e, the type of x cannot be opened using [varopen]. The [app] rule requires that the function e1 , the argument e2 , and the body of the function have the same effect . The [gen] rule derives a polymorphic type. Similar to the value restriction in ML we only allow values v [19], which is necessary for soundness of the translation from ImpKoka to System F + restrict. The [inst] rule is completely standard. The [let] rule is used to bind values with polymorphic types. As in the [gen] rule, the expression being bound is a value, and must have a total effect  . Notice that bound variable x is added to the context Δ, not Γ . Therefore, in the derivation of the body e2 , the type of x can be opened via [varopen].

152

K. Ikemori et al.

Fig. 5. Type substitution and type ordering

The [perform], [handler] and [ops] rules are also standard and used for algebraic effects and handlers. The [perform] rule is used to type an operation call. The type in the conclusion is a monotype, which is instantiated by using a sequence of types τ . The notation op : ∀αk .τ1 → τ2 ∈ Σ(l ) of the premise means that the operation op : ∀αk .τ1 → τ2 belongs to the effect signature corresponding to the effect label l . The [handler] rule is used to type a handler. It takes a thunked computation (action) of type () → l |  τ and handles the effect l . The [ops] rule takes care of operation clauses of a handler. The type of each operation clause is a nested function type of the form ∀αki . τ1i →  ((τ2i →  τ ) →  τ ), where τ1i is the input type of the operation, and τ2i →  τ is the type of the continuation. The condition αki ∈ ftv(, τ ) is necessary for type preservation of the translation from ImpKoka to System F + restrict.

4

Syntax-Directed Type Inference Rules

In this section, we formalize the syntax-directed type inference rules with open and close, following [6,10]. These rules allow us to determine which typing rule to apply to an expression from the syntax. In what follows, we first define type substitution and type ordering, and then elaborate the key cases of the inference rules. 4.1

Type Substitution

Figure 5 shows the definition of type substitution, which is inspired by [4]. The judgment  θ : Ξ 1 ⇒ Ξ 2 means substitution θ replaces type variables in context Ξ 1 with types well-formed under context Ξ 2 . There are two formation rules for substitutions: [empty] forms an empty substitution, and [extend] extends a substitution θ with [αk := τ ]. As a convention, we write id to mean the identity substitution. 4.2

Type Ordering

Figure 5 shows the definition of type ordering, which is similar to that of System F. The judgment Ξ  σ1 σ2 means type σ2 is more specific than type σ1

Sound and Complete Type Inference for Closed Effect Rows

153

Fig. 6. Syntax-directed type inference rules (excerpt)

under context Ξ. The context Ξ is used to inspect the kinds of the types τ that replace the type variables αk . For example, the following relationship holds. Ξ  ∀α . α →   α int →   int Ξ  ∀α μ . α → μ α ∀β. (β → exn β) → exn (β → exn β) Using type ordering, we define type equivalence as follows. σ1 = σ 2 ⇔ σ 1 σ 2 ∧ σ 2 σ 1 4.3

Inference Rules with open and close

Figure 6 is an excerpt of the syntax-directed type inference rules, consisting of those rules that are changed from the declarative rules. Compared to the declarative rules we saw in Sect. 3.2, there are no rules corresponding to [gen] and [inst], because these rules are not syntax directed. All other rules are identical to the declarative rules. Another difference is that we use two auxiliary functions Gen(·, ·, ·) and Close(·). The Gen function is the standard generalization function, with a standard definition of free type variables. The Close function closes the effect row of a function type. For example, Close(∀μ α. α → μ α) = ∀α. α →   α. Gen(Γ , Δ, τ ) = ∀(ftv(τ ) − ftv(Γ , Δ)). τ Close(∀μ αk . τ1 → l1 , . . ., ln | μ τ2 ) = ∀αk . τ1 → l1 , . . . , ln  τ2 iff μ ∈ ftv(τ1 , τ2 ) Close(σ) = σ otherwise

154

K. Ikemori et al.

Among the key rules, [var] is standard and derives the type instantiated by τ . The [varopen] rule derives an open function type from a closed one by inserting an arbitrary effect row. The [let] rule generalizes the type of the bound expression using Gen, and derives the type of the body under an extended type context Δ, x : Close(σ). In the [ops] rule, we derive a monomorphic type without the type variables bound by the quantifier. The syntax-directed type inference rules are sound and complete with respect to the declarative type inference rules. Theorem 1. (syntax-directed inference rules is sound) If Ξ | Γ | Δ s e : τ |  then Ξ | Γ | Δ  e : τ | . Theorem 2. (syntax-directed inference rules is complete) If Ξ | Γ | Δ  e : σ |  then Ξ  | Γ | Δ s e : τ | , where Ξ ⊆ Ξ  and Ξ   Gen(Γ , Δ, τ ) σ. 4.4

Principles on the Use of [LET] Rule

It is important that the Close function is applied only in the [let] rule. The reason is that, if Close is used to close a function type that is not universally quantified, the type system cannot track the effects to be handled. Let us illustrate the problem using a variation of Close and [lam] rule. We first define Close1 as a function that closes the effect variable of a monomorphic function type. For example, Close1 (α → μ α) = α →   α, where Γ = ∅ and Δ = ∅. Close1 (τ1 → l1 , . . ., ln | μ τ2 ) = τ1 → l1 , . . . , ln  τ2 iff μ ∈ ftv(τ1 , τ2 ) otherwise Close1 (τ ) = τ We next define [lam1] as a typing rule that closes the monomorphic function type of a lambda-bound variable using Close1 and derives the type of the body under an extended type context Δ, x : τ1 . This allows more functions to have a closed function type as their domain. τ1 = Close1 (τ1 ) Ξ | Γ | Δ, x : τ1 s e : τ2 |  Ξ wf τ1 : ∗ Ξ wf  : eff [lam1] Ξ | Γ | Δ s λx . e : τ1 →  τ2 |  With [lam1], it is possible to derive wrong effects. Consider the following expression. let f = λg. g () in f (λ_. perform ask -int ()) First, we derive ∅ | ∅ | ∅ s λg. g () : (() → μ α) →   α by the [lam1] and [varopen] rules. Next, we extend the type context Δ with f : ∀μ α. (() → μ α) →   α by the [let] rule. Then, we obtain the following derivation by the [app] rule. ∅ | ∅ | f : ∀μ α. (() → μ α) →   α s f (λ_. perform ask -int ()) : int |  

Sound and Complete Type Inference for Closed Effect Rows

155

This typing judgment is clearly wrong, because it does not track the read2 effect. The problem can be avoided if we use Close only in the [let] rule: in that case, f will be given a correct type ∀μ. α (() → μ α) → μ α. 4.5

Fragility of [LET] Rule

An unfortunate aspect of our current rules is that the [let] rule is fragile in the sense that insertion of a let-binding may change the typability of programs. Let us consider the following functions: remote = λf . perform ask -bool () foo = λf . remote f ; f () In our type system, the type of the function remote is inferred as α → read2 | μ bool. The function foo is also well-typed. First, the lambdabound variable f is added to the context Γ , second, the type of remote f is inferred as Ξ | Γ | Δ s remote f : bool | read2 | μ by the [app] rule, and finally, the type of f is inferred as Ξ | Γ | Δ s f : () → read2 | μ β | read2 | μ by the [app] rule, where e1 ; e2 is a syntax sugar of (λ_. e2 ) e1 . Therefore, the type of function foo is inferred as (() → read2 | μ β) → read2 | μ β and foo is judged well-typed. Suppose though that we have a function remote that is explicitly annotated with type (() →   ()) → read2  bool. remote : (() →   ()) → read2  bool remote = λf . perform ask -bool () foo = λf . remote f ; f () bar = λf . remote f ; let g = f in g () Here foo and bar only differ in the explicit let-binding for f . The naive HindleyMilner type inference without open and close mechanisms rejects both foo and bar , because a closed function type cannot be opened. In contrast, our inference rules reject foo but accept bar . The type of remote f is inferred as Ξ | Γ | Δ s remote f : bool | read2 , and the type of f is unified to a closed type () →   () by remote. Its type cannot be opened to () → read2  (), because the lambda-variable f is included in Γ and cannot be applied the [varopen] rule. Hence, we cannot apply the [app] rule to f (). On the other hand, the bar definition is well-typed. The function f is now a let -bound variable, thus its type can be opened to () → read2  () by the [varopen] rule. Hence, we can apply the [app] rule to g (). This is clearly not desirable and we would like to address this in future work. On the other hand, it is not uncommon to find this form of fragility in practical type systems (like the monomorphism restriction in Haskell, inference for GADT’s [14], etc.) and it may work out fine in practice. Experiments on all the standard libraries of Koka (~15000 lines) showed only 2 instances where a let binding was required.

156

K. Ikemori et al. dom : Subst → TypeVars dom(∅) = ∅ dom(θ[αk : = τ ]) = { αk } ∪ dom(θ) codom : Subst → MTypes codom(∅) = ∅ codom(θ[αk : = τ ]) = { τ } ∪ dom(θ)

const : MTypes → Con const(∅) = ∅ const({ sk } ∪ τ ) = { sk } ∪ const(τ ) const({ τ } ∪ τ ) = const(τ )

tail : Eff -> Eff tail( ) = tail( µ ) = µ tail( l | ) = tail( )

(−) : (Subst, TypeVars) → Subst ∅ − αk = ∅ θ[β k : = τ ] − αk = θ − αk (β k ∈ αk ) k k k k θ[β : = τ ] − α = (θ − α ) [β : = τ ] (β k ∈  αk )

Fig. 7. Auxiliary functions

5

Type Inference Algorithm

In this section, we formalize the type inference algorithm with open and close as an extension of Algorithm W [3]. We first define auxiliary functions (Sect. 5.1), and then discuss the unification algorithm (Sect. 5.2) and the type inference algorithm (Sect. 5.3). 5.1

Auxiliary Functions

In Fig. 7, we define auxiliary functions. The functions dom( ·), codom( ·) , and tail( ·) are completely standard. The dom function takes a substitution θ and returns a set of type variables that are included in the domain of θ. The codom function also takes a substitution θ and returns a set of monomorphic types that are included in the codomain of θ. The tail function takes an effect row and returns the tail of the effect row. Note that the function is defined only on non-empty effect rows. The (−) and const functions are used in the unifyOp function (Sect. 5.2). The former takes a substitution θ and set of type variables αk , and returns a new substitution with αk removed from the domain of θ. The latter takes a set of monomorphic types, and returns a set of skolem constants. 5.2

Unification Algorithm

In Fig. 8, we define the unification algorithm. The algorithm is a natural extension of the standard Robinson unification algorithm [18]. It consists of three functions unify(·, ·, ·), unifyEffect(·, ·, ·), and unifyOp(·, ·, ·), which take care of value types, effect types, and the type of operation clauses, respectively. Among these functions, unify and unifyEffect are standard. The unifyOp function originates from the special treatment of Koka’s operation clauses. Let us look at the unify and unifyEffect functions. Given a triple (Ξ, , l ) of a type context, an effect row type, and an effect label, unifyEffect returns a

Sound and Complete Type Inference for Closed Effect Rows

157

Fig. 8. Unification algorithm

triple (Ξ1 , θ1 , 1 ) of a new type context, a substitution, and an effect row. We can transform  into an effect row whose head effect label is l . As an example, let us consider the following unification problem. unifyEffect(Ξ, read2 | μ, exn) This succeeds and returns the following effect row and substitution: 1 = read2 | μ1 

θ1 = id [μ := exn | μ1 ]

158

K. Ikemori et al.

The unifyEffect function is sound. That is, if unifyEffect(Ξ, , l ) succeeds, it returns a substitution θ1 and an effect row 1 that satisfy θ1 () ≡ l | θ1 (1 ). Theorem 3. (unifyEffect is sound) If Ξ wf  : eff, Ξ wf l : lab and unifyEffect(Ξ, , l ) = (Ξ 1 , θ1 , 1 ), then  θ1 : Ξ ⇒ Ξ 1 and θ1 () ≡l | θ1 (1 ). The unifyEffect function is also complete. That is, if  can be rewritten to an effect row of the form l | θ( ) by the substitution θ, unifyEffect(Ξ, , l ) succeeds and returns the most general substitution θ1 . Theorem 4. (unifyEffect is complete) If Ξ wf  : eff, Ξ wf l : lab,  θ : Ξ ⇒ Ξ 2 and θ() ≡ l | θ( ), then unifyEffect(Ξ, , l ) = (Ξ 1 , θ1 , 1 ) and there exists  θ2 : Ξ 1 ⇒ Ξ 2 such that θ = θ2 ◦ θ1 . The unify function is similar to unifyEffect. Given a triple (Ξ, τ1 , τ2 ) of a type context and two monomorphic types, unify returns a pair (Ξ1 , θ1 ) of a new type context and a substitution. The most interesting case of the unification algorithm is unify(Ξ, l | 1 , l  | 2 ) that corresponds to [uni-row] case in the [9]. First, unifyEffect(Ξ, l  | 2 , l ) in the first line returns a substitution θ1 and an effect row 3 , where l | 3  ≡ θ1 (l  | 2 ). Next, tail(1 ) ∈ dom(θ1 ) in the second line confirms that effect variable of 1 is not included in the domain of θ1 . This is needed to ensure the termination of the unification algorithm. Then, unify(Ξ1 , θ1 (1 ), 3 ) in the third line returns a substitution θ2 , where θ2 (3 ) ≡ (θ2 ◦ θ1 )(1 ). Thus, we obtain the following equation. (θ2 ◦ θ1 )(l | 1 ) ≡ (θ2 )(l | 3 ) ≡ (θ2 ◦ θ1 )(l  | 2 ) The unification algorithm is sound: if unify(Ξ, τ1 , τ2 ) succeeds, it returns the substitution θ1 that unifies τ1 and τ2 . Theorem 5. (unify is sound) If Ξ wf τ1 : k , Ξ wf τ2 : k , and unify(Ξ,τ1 , τ2 ) = (Ξ 1 , θ1 ), then  θ1 : Ξ ⇒ Ξ1 and θ1 (τ1 ) = θ1 (τ2 ). The unification algorithm is also complete: if two types τ1 and τ2 are unifiable, unify(Ξ, τ1 , τ2 ) succeeds and it returns the most general substitution θ1 . Theorem 6. (unify is complete) If Ξ wf τ1 : k , Ξ wf τ2 : k ,  θ : Ξ⇒ Ξ2 and θ(τ1 ) = θ(τ2 ), then unify(Ξ, τ1 , τ2 ) = (Ξ 1 , θ1 ) and there exists  θ2 : Ξ1 ⇒ Ξ2 such that θ = θ2 ◦ θ1 . We now look at the unifyOp function. The function takes a triple (Ξ, ∀αk . τ1 , τ2 ) of a type variable context, a type scheme, and a monomorphic type τ , and returns a pair (Ξ1 , θ1 ) of a new type context and a substitution. Following [10], we use skolem constants to ensure that bound type variables αk do not escape or occur free in the substitution [sk → αk ] ◦ θ1 . The algorithm reads as follows.

Sound and Complete Type Inference for Closed Effect Rows

159

We first replace the free type variables αk of τ1 with fresh skolem constants sk , and unify the resulting type with τ2 . When we obtain a substitution θ1 as the result, we check the codomain of θ1 − ftv(τ2 ) does not contain skolem constants sk , using the const function. If this checking succeeds, we construct a new substitution by replacing the skolem constants sk in θ1 back to type variables αk , and return the resulting substitution. As an example, consider the following unification problem: unifyOp(Ξ, ∀α. α → exn ((α → exn int) → exn int), β 1 → μ β 2 ) This succeeds and returns the following substitution: [s → α] ◦ θ1 = id [β 1 := α, β 2 := ((α → exn int) → exn int), μ := exn] where θ1 = id [β 1 := s, β 2 := ((s → exn int) → exn int), μ := exn]. The soundness and completeness of unifyOp can be proven in a similar way to that of unify and unifyEffect. Theorem 7. (unifyOp is sound) If Ξ wf ∀αk . τ1 : k , Ξ wf τ2 : k and unifyOp(Ξ, ∀αk . τ1 , τ2 ) = (Ξ 1 ,θ1 ), then  θ1 : Ξ ⇒ Ξ1 and θ1 (∀αk . τ1 )= ∀αk . θ1 (τ2 ). Theorem 8. (unifyOp is complete) If Ξ wf ∀αk . τ1 : k , Ξ wf τ2 : k ,  θ : Ξ ⇒ Ξ2 and θ(∀αk . τ1 ) = ∀αk . θ(τ2 ), then unifyOp(Ξ, ∀αk . τ1 , τ2 ) =(Ξ 1 , θ1 ) and there exists  θ2 : Ξ1 ⇒ Ξ2 such that θ = θ2 ◦ θ1 . 5.3

Type Inference Algorithm

In Figs. 9 and 10, we define the type inference algorithm. The algorithm is an extension of Algorithm W [3] with kinding and a row-based effect system. The functions infer (·, ·, ·, ·) and inferHandler (·, ·, ·, ·) are defined by mutual induction. Given a quadruple (Ξ, Γ , Δ, e) of a type variable context, two typing contexts, and an expression, infer returns a quadruple (Ξ, θ, τ, ) of a new type variable context, a substitution, a monomorphic type, and an effect row. The effect row  represents the effect performed by the expression e. Let us go through individual cases. In the variable case, if x has a closed function type and resides in Δ, infer yields the most general type by opening the closed effect row to μ1 . The infer function also yields an arbitrary effect μ2 as x is a value. If x does not have a closed function type, infer generates a new k

type variable β as in a standard type inference algorithm. The abstraction and application cases are standard, except that they involve inference of effect rows. In the case of a let-expression, the bound expression is generalized by Gen and the effect row of the function type is closed by Close. Then, the type context

160

K. Ikemori et al.

Fig. 9. Type inference algorithm

Δ is extended with the closed effect row and used for the inference of the body of the let-expression. Consider the inference of the following let-expression. let f = λx . x in f 1

Sound and Complete Type Inference for Closed Effect Rows

161

Fig. 10. Type inference algorithm for handlers

The type of f is inferred to be ∀α μ. α → μ α, where μ is an effect variable. Since the effect row of the function is closed immediately after generalization, the inference of the body f 1 is done by infer (Ξ, Γ , (Δ, f : ∀α. α →   α) , f 1). In the case of an operation call, infer simply returns the type instantiated with a new effect variable. Here, Op(·, ·) is an auxiliary function that selects from Σ the signature of the operation op. In the handler case, we use two auxiliary functions inferHandler and Label. The inferHandler function infers the type of a handler. It receives a quadruple (Ξ, Γ , Δ, h) of a type variable context, two typing contexts, and a handler, and returns a quintuple (Ξ, θ, τ, l , ) of a new type variable context, a substitution, a monomorphic type, an effect label, and an effect row. Here, τ is the return type of the continuation captured by the handler h, l is the effect label handled by h, and  is the rest of the effect row. The Label function returns the effect label corresponding to the given operation. It is important to use unifyOp instead of unify in the type inference of handlers. For example, consider the following effect signature and handler: Σ = { l1 : { op : ∀α. α → α }} handler { op → λx k . k (x + 1) } This handler should be rejected for the following reason. First, the operation op is defined as having type ∀α. α → α. Therefore, the operation clause of op must have a type of the form ∀α. α → μ ((α → μ β) → μ β), where the input and output types of the operation are universally quantified. Second, the operation clause λx k . k (x + 1) is inferred to have type int → μ ((int → μ β) → μ β), where the input and output types are a concrete type int. If we use unifyOp, unification of ∀α. α → μ ((α → μ β) → μ β and int → μ ((int → μ β) → μ β)

162

K. Ikemori et al.

fails, because the bound variable α and int cannot be unified. On the other hand, if we use unify, unification of the two types succeeds, because we would pass a monomorphic type α → μ ((α → μ β) → μ β) to unify, which can be unified with int → μ ((int → μ β) → μ β). The soundness and completeness with respect to the syntax-directed inference rules of infer can be proven by induction on the structure of e. Theorem 9. (infer is sound with respect to syntax-directed inference rules) If infer (Ξ, Γ , Δ, e) = (Ξ1 , θ, τ, ), then  θ : Ξ ⇒ Ξ1 and Ξ1 | θ(Γ ) | θ(Δ) s e : τ | . Theorem 10. (infer is complete with respect to syntax-directed inference rules) If  θ : Ξ ⇒ Ξ2 and Ξ2 | θ(Γ ) | θ(Δ) s e : τ | , then infer (Ξ, Γ ,Δ,e) = (Ξ1 , θ1 , τ, ) , and there exists  θ2 : Ξ1 ⇒ Ξ2 such that θ = θ2 ◦ θ1 . Using the results so far, we can prove the main theorems: the type inference algorithm for the declarative inference rules is sound and complete. Theorem 11. (infer is sound) If infer (Ξ, Γ , Δ, e) = (Ξ1 , θ, τ, ), then  θ : Ξ ⇒ Ξ1 and Ξ1 | θ(Γ ) | θ(Δ)  e : τ | . Proof. By Theorem 1 and Theorem 9. Theorem 12. (infer is complete) If  θ : Ξ ⇒ Ξ2 and Ξ2 | θ(Γ ) | θ(Δ)  e : τ | , then infer (Ξ, Γ , Δ, e) = (Ξ1 , θ1 , τ, ), and there exists  θ2 : Ξ1 ⇒ Ξ2 such that θ = θ2 ◦ θ1 . Proof. By Theorem 2 and Theorem 10.

6

Related Work

There are a variety of languages supporting effect handlers in the literature. Eff [1] is an ML-like language that employs the Hindley-Milner type inference [8, 16]. Differently from Koka, Eff has an effect system based on subtyping. As a result, the type inference algorithm [8] of Eff is more complex than the one presented in this paper. Frank [2,13] is a language that has effect rows and effect polymorphism similar to Koka. The difference is that Frank treats all effect rows as open ones by implicitly inserting effect variables. This means the user does not need to write type variables to express effect polymorphism, but it also means error messages may contain effect variables that the user did not write. Moreover, this approach to treating effect rows is not suitable for evidence passing because it gives rise to unnecessary search for handlers.

Sound and Complete Type Inference for Closed Effect Rows

163

Links [5] is another language with a row-based effect system and effect polymorphism. What is different from Koka is that effect rows in Links are based on Remy’s record types [17], where each effect label is annotated with a presence type. Presence types increase the expressiveness of the language, but they also complicate the inference algorithm. There is a type inference algorithm for an older version of Koka [11], which solely supports built-in effects such as exceptions and references. Similar to the current Koka, it has effect rows [9] and effect polymorphism. The type inference algorithm is an extension of the Hindley-Milner algorithm, but it infers an open effect row for all functions due to the lack of open and close.

7

Conclusion and Future Work

In this paper, we formalized a type inference algorithm with open and close and proved its soundness and completeness. The inference algorithm helps the Koka compiler statically determines handlers, and thus improves performance. Moreover, it allows the compiler to display precise signatures. In future work, we plan to improve the current typing rules in order to make the typability of programs robust against small syntactic rewrites. One possible approach is to add the open keyword to avoid implicit opening of closed function types. This construct is similar to · in [4] that avoids implicitly instantiating variables. However, this choice gives a gap between the formalization and implementation of Koka, because the current Koka implicitly opens closed functions types. Acknowledgement. We would like to thank the anonymous reviewers for their valuable comments and the members of Masuhara laboratory for helpful advice. We are particularly grateful to Naoya Furudono for useful suggestions and discussions. This work was supported in part by JSPS KAKENHI under Grant No. JP19K24339.

Appendix In this appendix, we present an extension of System F , which we call System F + restrict, and a type-directed translation from ImpKoka to System F + restrict. The translation allows us to prove the type soundness of ImpKoka without directly defining an operational semantics for ImpKoka. This is a well-known technique, and is used in [10], for instance. The target calculus of the translation has a new construct restrict, which is necessary for establishing soundness of the translation.

A

System F + restrict

In Figs. 12, 13 and 14, we define the syntax, operational semantics, and typing rules of System F  + restrict. The typing rule [restrict] extends the closed effect row l1 , .., ln  of the expression e to l1 , . . .ln | , where  is an arbitrary effects.

164

K. Ikemori et al.

Fig. 11. Translation to system F +restrict

We can prove the type soundness of System F + restrict by showing the following theorems. Theorem 13. (progress) If ∅  e1 : σ |   then either e1 is a value or e1 −→ e2 . Theorem 14. (preservation) If ∅  e1 : σ |   and e1 −→ e2 then ∅  e2 : σ |  .

Sound and Complete Type Inference for Closed Effect Rows

Fig. 12. Types of system F +restrict

Fig. 13. Expressions and operational semantics of F +restrict

165

166

K. Ikemori et al.

Fig. 14. Typing rules of F +restrict

B

Type-Directed Translation to System F + restrict

In Fig. 11, we next define the type-directed translation from ImpKoka to System F + restrict. The judgment Ξ | Γ | Δ  e : σ |   e  states that an expression e has type σ and effect  under the type variable context Ξ and typing contexts Γ and Δ, and translates to an expression e  of System F + restrict. We can easily prove the soundness of the type-directed translation. Theorem 15. (soundness of type-directed translation) If Ξ | Γ | Δ  e : σ |   e  then Γ , Δ  e  : σ | . Proof. By straightforward induction on the typing derivation.

Sound and Complete Type Inference for Closed Effect Rows

167

References 1. Bauer, A., Pretnar, M.: Programming with algebraic effects and handlers. J. Logic. Algebraic Methods Program. 84(1), 108–123 (2015) 2. Convent, L., Lindley, S., McBride, C., McLaughlin, C.: Doo bee doo bee doo. J. Funct. Program. 30, e9 (2020). To appear in the special issue on algebraic effects and handlers 3. Damas, L., Milner, R.: Principal type-schemes for functional programs. In: DeMillo, R.A. (ed.) Conference Record of the Ninth Annual ACM Symposium on Principles of Programming Languages, Albuquerque, New Mexico, USA, January 1982, pp. 207–212. ACM Press (1982). https://doi.org/10.1145/582153.582176 4. Emrich, F., Lindley, S., Stolarek, J., Cheney, J., Coates, J.: FreezeML: complete and easy type inference for first-class polymorphism. In: Donaldson, A.F., Torlak, E. (eds.) Proceedings of the 41st ACM SIGPLAN International Conference on Programming Language Design and Implementation, PLDI 2020, London, UK, 15– 20 June 2020, pp. 423–437. ACM (2020). https://doi.org/10.1145/3385412.3386003 5. Hillerström, D., Lindley, S.: Liberating effects with rows and handlers. In: Chapman, J., Swierstra, W. (eds.) Proceedings of the 1st International Workshop on Type-Driven Development, TyDe@ICFP 2016, Nara, Japan, 18 September 2016, pp. 15–27. ACM (2016). https://doi.org/10.1145/2976022.2976033 6. Jones, M.P.: A theory of qualified types. In: Krieg-Brückner, B. (ed.) ESOP 1992. LNCS, vol. 582, pp. 287–306. Springer, Heidelberg (1992). https://doi.org/10.1007/ 3-540-55253-7_17 7. Kammar, O., Plotkin, G.D.: Algebraic foundations for effect-dependent optimisations. In: Proceedings of the 39th Annual ACM SIGPLAN-SIGACT Symposium on Principles of Programming Languages, POPL 2012, New York, NY, USA, pp. 349–360. ACM (2012). https://doi.org/10.1145/2103656.2103698 8. Karachalias, G., Pretnar, M., Saleh, A.H., Vanderhallen, S., Schrijvers, T.: Explicit effect subtyping. J. Funct. Program. 30, e15 (2020). https://doi.org/10.1017/ S0956796820000131 9. Leijen, D.: Extensible records with scoped labels. In: Proceedings of the 2005 Symposium on Trends in Functional Programming, pp. 297–312 (2005) 10. Leijen, D.: HMF: simple type inference for first-class polymorphism. In: Proceedings of the 13th ACM Symposium of the International Conference on Functional Programming, ICFP 2008, Victoria, Canada, September 2008. https://doi.org/10. 1145/1411204.1411245 11. Leijen, D.: Koka: programming with row polymorphic effect types. In: 5th Workshop on Mathematically Structured Functional Programming, MSFP 2014 (2014). doi: 10.4204/EPTCS.153.8 12. Leijen, D.: Type directed compilation of row-typed algebraic effects. In: Proceedings of the 44th ACM SIGPLAN Symposium on Principles of Programming Languages (POPL 2017), Paris, France, pp. 486–499, January 2017. https://doi.org/ 10.1145/3009837.3009872 13. Lindley, S., McBride, C., McLaughlin, C.: Do be do be do. In: Proceedings of the 44th ACM SIGPLAN Symposium on Principles of Programming Languages (POPL 2017), Paris, France, pp. 500–514, January 2017. https://doi.org/10.1145/ 3009837.3009897 14. Jones, S.P., Washburn, G., Weirich, S.: Wobbly types: type inference for generalised algebraic data types. MS-CIS-05-26. Microsoft Research, July 2004

168

K. Ikemori et al.

15. Plotkin, G.D., Power, J.: Algebraic operations and generic effects. Appl. Categ. Struct. 11(1), 69–94 (2003). https://doi.org/10.1023/A:1023064908962 16. Pretnar, M.: Inferring algebraic effects. Log. Methods Comput. Sci. 10(3) (2014). https://doi.org/10.2168/LMCS-10(3:21)2014 17. Rémy, D.: Type inference for records in natural extension of ML. In: Theoretical Aspects of Object-Oriented Programming, pp. 67–95 (1994) 18. Robinson, J.A.: A machine-oriented logic based on the resolution principle. J. ACM 12(1), 23–41 (1965). https://doi.org/10.1145/321250.321253 19. Wright, A.K.: Simple imperative polymorphism. LISP Symb. Comput. 8(4), 343– 355 (1995) 20. Xie, N., Brachthauser, J., Hillerstrom, D., Schuster, P., Leijen, D.: Effect handlers, evidently. In: The 25th ACM SIGPLAN International Conference on Functional Programming (ICFP). ACM SIGPLAN, August 2020. https://www.microsoft. com/en-us/research/publication/effect-handlers-evidently/ 21. Xie, N., Leijen, D.: Generized evidence passing for effect handlers - efficient compilation of effect handlers to C. In: Proceedings of the 26th ACM SIGPLAN International Conference on Functional Programming (ICFP 2021). Virtual, August 2021

Towards Efficient Adjustment of Effect Rows Naoya Furudono1(B) , Youyou Cong1 , Hidehiko Masuhara1 , and Daan Leijen2 1 Tokyo Institute of Technology, Tokyo, Japan [email protected], [email protected], [email protected] 2 Microsoft Research, Seattle, USA [email protected]

Abstract. Koka is a functional programming language with native support for algebraic effects and handlers. To implement effect handler operations efficiently, Koka employs a semantics where the handlers in scope are passed down to each function as an evidence vector. At runtime, these evidence vectors are adjusted using the open constructs to match the evidence for a particular function. All these adjustments can cause significant runtime overhead. In this paper, we present a novel transformation on the Koka core calculus that we call open floating. This transformation aims to float up open constructs and combine them in order to minimize the adjustments needed at runtime. Open floating improves performance by 2.5× in an experiment. Furthermore, we formalize an aspect of row-based effect typing, including the closed prefix relation on effect rows, which clarifies the constraint on open floating. Keywords: Algebraic effect and handlers Compiler optimization

1

· Type and effect system ·

Introduction

Algebraic effect handlers [14] are a language feature for user-defined effects. For instance, using effect handlers, we can support exception, asynchronous programming [8], nondeterminism, and so on, not as builtin features but as libraries. While effect handlers are convenient, they incur more runtime overhead compared to native effects. In order to fill in the gap of the performance, suitable semantics have been explored [2,15,19,20]. In this study, we focus on the evidence passing semantics [20], which is employed by the Koka language [7,9,20]. The key idea of the semantics is to pass around a vector of handler implementations, called an evidence vector, and propagate it to algebraic effect operation calls, exposing optimization opportunities. The row-based type-and-effect system ensures the correctness of the dynamic semantics, where the static effect row type corresponds directly to the shape of the dynamic evidence vector at runtime. The Koka compiler automatically inserts open constructs during type inference to adjust evidence vectors at runtime. Unfortunately, each adjustment c Springer Nature Switzerland AG 2022  W. Swierstra and N. Wu (Eds.): TFP 2022, LNCS 13401, pp. 169–191, 2022. https://doi.org/10.1007/978-3-031-21314-4_9

170

N. Furudono et al.

incurs runtime cost and, being type-directed, the automatic insertion tends to generate many redundant open calls around function applications. In this paper, we present the open floating algorithm to remove such opens as a compile-time optimization. The algorithm first removes existing opens in a topdown way and then re-assigns effect adjustment constructs back in a bottom-up way. Unlike type inference phase insertion, the re-assignment is driven by the now explicit effect types, ensuring preservation of the meaning of programs. To give the reader a rough idea of the algorithm, we present programs before and after open floating. handler { ask → λx . λk . k 3 } λ_. let x = open read  safediv (3, 2) in let y = open read  safediv (3, x ) in open read  safediv (3, y)



handler { ask → λx . λk . k 3 } λ_. restrict   ( let x = safediv (3, 2) in let y = safediv (3, x ) in safediv (3, y))

The one on the right is faster than the one on the left, because the former has less adjustment operations open and restrict. Both of them perform the same adjustment. The difference is that open is used solely to functions, while restrict can be applied to general expressions. This is essential to share a single adjustment over multiple function applications. In Sect. 2, we give an overview of our study, and in the subsequent sections, we discuss the following contributions. – We define System Fpwo , a system of effect handlers with the open construct. The system is a core calculus of the Koka language, and is an extension of System Fpw by Xie and Leijen [20]. We elaborate on effect typing in the extended system, showing • the way the type system checks the use of effect handlers (Sect. 3.3) • an advantage of using rows for effect types rather than sets, which are popular in effect handler calculi [1,16] (Sect. 3.4) • an effect type restriction on open and restrict, which we call the closed prefix relation (Sect. 3.4). In particular, the closed prefix relation clarifies what program transformations are allowed. This helps us define open floating. – We give a definition of the open floating algorithm, which floats up redundant opens (Sect. 4) by analyzing the effect type of expressions. – We implemented open floating in the Koka compiler [11]. – We evaluated open floating via a preliminary benchmark (Sect. 5), which improved performance by 2.5×. Based on the results, we make it clear what kind of programs benefit from open floating. We discuss future work in Sect. 6 and related work in Sect. 7.

Towards Efficient Adjustment of Effect Rows

2

171

Overview

In this section, we give an overview of this thesis. We first give an overview of the calculus of study, and explain the need for open. We then show an example with redundant opens and describe the idea of our solution. 2.1

Effect Handlers

Algebraic effects are declared with an effect label l and a list of operation signatures. For instance, suppose we have a read effect with a single operation ask , which takes a unit argument and returns an integer value. read : { ask : () → int } An effect handler for read specifies what the ask operation should do when it is called in the handled expression. handler { ask → λ_. λk . k 3 } λ_. perform ask () + perform ask () In this example, perform ask () calls operation ask with argument (), which is evaluated to 3 according to the handler. The behavior of an operation call is formalized as follows: (1) find the innermost handler of the effect, (2) capture the resumption – continuation delimited by the handler –, and (3) apply the handler clause to the argument and the resumption. 1. handler { ask → λ_. λk . k 3 } λ_. perform ask () + perform ask () 2. resume = λz . handler { ask → λ_. λk . k 3 } λ_. z + perform ask () 3. (λ_. λk . k 3) () resume The handler resumes the resumption with argument 3, so that the example is evaluated to the following. handler { ask → λ_. λk . k 3 } λ_. 3 + perform ask () The two occurrences of perform ask () are both evaluated to 3, therefore the whole expression is evaluated to 6. Using effect handlers, it is easy to combine different effects in a single program. Let us combine read with the exception effect exn, which has an operation throw of type ∀α.int → α exn : { throw : ∀α.int → α } Using a handler for exn as we did for read , we can perform the throw operation. handler { ask → λx . λk . k 3 } (λ_. handler { throw → λx . λk . x } (λ_. perform ask () + perform ask () + perform throw 1)) In this program, perform ask () is evaluated to 3 as before, but the entire expression is evaluated to 1 due to perform throw 1. The handler for the throw operation discards the resumption k and returns the argument x , which exits the computation out of the handler expression.

172

2.2

N. Furudono et al.

Evidence Passing Semantics and Row-Based Effect System

Among different formalizations of the dynamic semantics of effect handlers, we adopt the evidence passing semantics (EPS) [20], which exploits the invariants derived from the row-based effect system and allows the compiler to translate programs to efficient code. Under the EPS, we pass all handlers in scope to the handled expression so that operation calls can access their handler locally. The operation clauses passed to expressions are represented as an evidence vector [19]. For instance, in the read and exn examples discussed above, the expression perform ask () is performed with the evidence vector of the form exn : {throw → . . .}, read : {ask → . . . }. Xie and Leijen [20] show that EPS often allow us to avoid lookups and resumption captures, which are one of main sources of inefficiency in effect handler execution. The static semantics of our calculus is defined by a row-based effect system. In the effect system, every expression is related to an effect row type in addition to a usual type. Effect rows indicate what kind of evidence vector is provided from the context to evaluate the expression. For instance, in the example with read and exn, the expression perform ask () + perform ask () + perform throw 1 has type int and effect row exn, read . The typing rules maintain the correspondence between evidence vectors and effect rows. For instance, a function application uses the same evidence vector for the function, the argument, and the β-reduced expression. Correspondingly, the typing rule for function application requires the effect rows of the three parts (occurrences of  in the premises) to agree with that of the entire expression (occurrence of  in the conclusion). Γ  e2 : σ1 |  Γ  e1 : σ1 →  σ2 |  [APP] Γ  e1 e2 : σ2 |  We ignore the order of labels in effect rows (except for parameterized effect labels that are discussed in Section 3.1.2 and Sect. 3.4). For instance, we regard two rows exn, read  and read , exn  as equivalent. This flexible row equivalence allows the type system to ignore the order of handlers in evaluation contexts. As a consequence, both programs below are judged well-typed as one would expect. handler { ask → λx . λk . k 3 } (λ_. handler { throw → λx . λk . x } f ) where

handler { throw → λx . λk . x } (λ_. handler { ask → λx . λk . k 3 } f )

f = λ_.perform ask () + perform ask () + perform throw 1

We formally define the effect row equivalence in Section 3.1.2 and discuss it in Sect. 3.4. 2.3

Effect Type Adjustment for Function Types

The typing rule [APP] is too restrictive on some occasions. Consider a function safediv of type (int, int) →   maybeint , which returns Nothing if the divider is 0, instead of throwing an exception. This function causes no effect, hence we

Towards Efficient Adjustment of Effect Rows

173

should be able to call the function in any context. However, the type system prevents us from calling safediv in certain contexts. For instance, the following expression is judged ill-typed. handler { ask → λx . λk . k 3 } λ_.safediv (3, 2) The expression safediv (3, 2) expects an evidence vector of type  , whereas the context provides an evidence vector of type read . Due to this inconsistency, the expression is rejected by the type system. To call functions with a “smaller” effect, we introduce the expression open  v into the calculus. At compile time, open allows a function to have a “bigger” effect type according to the following typing rule. Γ val v : σ1 →  σ2    [OPEN] Γ val open  v : σ1 →  σ2 At runtime, open adjusts evidence vectors so that the callee receives an evidence vector of the expected shape and thus runs correctly. Using open, we can make the above example well-typed. handler {ask → λx . λk . k 3} λ_.(open read  safediv )(3, 2) We call the smaller effect row a closed prefix of the larger one. The closed prefix relation is defined as: l1 , . . ., ln   l1 , . . ., ln |  

(n ≥ 0)

It turns out that different formulations are possible but some seemingly benign generalizations can make the type system unsound – we discuss the closed prefix relation in detail in Sect. 3.4. 2.4

Motivating Open Floating

Our calculus is designed as an intermediate language of a compiler. This means the user does not need to explicitly write opens; they are automatically inserted by the type inferencer. The user expression handler { ask → . . . } λ_.safediv (3, 2) is translated to explicitly typed handler  { ask → . . . } λ_.(open read  safediv ) (3, 2), for example. Unfortunately, naive insertion of opens makes programs inefficient. Consider a program that calls safediv three times. The compiler inserts opens into each function call as follows. handler  { ask → λx : int. λk : int →   int. k 3 } λ_. let x = open read  safediv (3, 2) in let y = open read  safediv (3, x ) in open read  safediv (3, y) As open causes evidence vector adjustment at runtime, having many open calls makes execution slow. In order to avoid this inefficiency, we design the open floating optimization that eliminates redundant open calls. By open floating, the above program is transformed to the following one.

174

N. Furudono et al.

handler  { ask → λx : int. λk : int →   int. k 3 } λread  _. restrict   ( let x = safediv (3, 2) in let y = safediv (3, x ) in safediv (3, y)) Here, restrict  e allows e to be typed with effect , which is smaller than the effect of the context. In this particular example, e is typed with  , not read . At runtime, as open does, restrict  e changes the shape of the evidence vector to fit  and pass it to e. In general, open floating erases open in a β-redex and re-assigns appropriate open or restrict to make the whole expression type check in a bottom-up way. The closed prefix relation plays an essential role in determining the new effect type of each sub-expression. We formally define open, restrict, and the closed prefix relation in Sect. 3, present the open floating algorithm in Sect. 4, and discuss a preliminary benchmark in Sect. 5.

3

System Fpwo

In this section, we present System Fpwo , a calculus with algebraic effect handlers and the open. The calculus is an extension of System Fpw [20], an explicitly typed polymorphic lambda calculus with effect handlers. The semantics is based on evidence passing semantics [20], which leads to an efficient implementation of effect handlers. Furthermore, both calculi have row-based effect types, which denote the static meaning of evidence vectors. We extend Fpw with open, restrict and parameterized effect labels. These features have previously been discussed by Leijen [10]. In that work, the idea of open was formalized as a typing rule, and parameterized effect labels were formalized in a way that does not fit well to our calculus. We have confirmed the soundness of the type system through the compiler implementation. We are currently developing formal proofs. We first introduce the syntax, dynamic semantics, and static semantics of Fpwo . After that, we describe the typing with effect rows, including the closed prefix relation. Effect typing plays an important role in this study, since open floating depends heavily on it. 3.1

Syntax

The syntax is defined in Fig. 1. Expressions. Expressions e include values v , applications e e, type applications e σ, let-bindings let x = e in e, prompt prompt m h e, yield yield m v , and restrict restrict  e. Prompts and yields are internal constructs that only appear as an intermediate result of evaluation. We will give the definition of internal constructs in Definition 1 more precisely.

Towards Efficient Adjustment of Effect Rows

175

Fig. 1. Syntax of system Fpwo

Values v include variables x , lambda abstractions λ x : σ.e, type abstractions Λαk .v , effect handlers handler h, operation calls perform op  σ, and open open  v . Handlers clauses h consist of a sequence of pairs of an operation name op and a function value f . The meta-variable f is syntactically a value, but we use it specifically as a function, which takes (type) arguments. The type system maintains the intention. Types. Types σ include type variables αk of kind k, type application for type constructors c k σ (where c k is applied to arguments σ), function types σ1 → σ2 (indicating the body of the function can cause effect ), and polymorphic types ∀αk .σ.1 By extending σ with type-level lambdas, we obtain a set of types τ that appear in effect signatures. Effect signatures are explained later in this section. Kinds k include the regular kind ∗, functions k→k, effect labels lab, and effect rows eff. Types of the function kind k→k are either an effect constant cl , a row type constructor _ | _ , or a type τ in an effect signature. Effect labels l are types of kind lab. Effect constants cl are of kind lab parameterized with zero or more types of regular kind. Effect labels are used to structure effect rows and evidence vectors, while effect constants are used for effect contexts and effect labels. Effect rows  include the empty effect  , extension with effect label l |  , and type variables αeff of kind eff. We use the following abbreviation for rows: . . l1 , . . ., ln |   = l1 | . . .  ln |   . . .  and l1 , . . ., ln  = l1 , . . ., ln |   . We use μ to denote type variable of kind eff. The equivalence of effect rows is defined in Fig. 2. We can ignore the order of occurrences of two effect labels 1

Kind annotations directly relate their types to the specific symbols associated with them, such as μ and cl . This allows us to use these symbols just as aliases.

176

N. Furudono et al.

Fig. 2. Effect row type equivalence

in a row if two labels consist of different effect constants. See Sect. 3.4 for details of effect rows. An effect context Σ is a sequence of pairs of an effect constant and an effect signature. It maintains the relation between the name of an effect and the type of its operations. We assume Σ is given externally in this calculus, while in practical language one may define Σ by top-level definitions. Effect signatures sig are a sequence of a pair of an operation name and its type. The type τ in an effect signature takes zero or more type arguments of regular kind. The type arguments will be passed if the effect is parameterized. We will show an example with type rule [PERFORM] in Sect. 3.3. Evidence Vectors. Evidence vectors w include the empty vector   and extension l : ev | w  with a pair of an effect label l and an evidence ev . Evidences ev are a triple (m, h, w ) of a marker m, a handler h, and an evidence vector w where h is defined. The evidence vector in the triple is key to general use of effect handlers, but the discussion is out of the scope of this paper. See [20] for details. 3.2

Dynamic Semantics

The dynamic semantics is defined in Fig. 3. The semantics consists of three rules: stepping −→, multi-stepping −→∗ , and reduction −→. Evaluation Steps. The rules (∗stepwR) and (∗stepwT ) defines multi-stepping as the reflexive transitive closure of stepping. The rules (step) and (stepw ) reduce a redex without and with an evidence vector w , respectively. In these rules, the evaluation context of the redex must be F, not E. F excludes prompt frames and restrict frames. The (promptw ) rule extends the evidence vector. Conversely, the (restrictw ) rule shortens the evidence vector using the select meta-function so that the shape of the new evidence vector fits the effect row  of the restrict frame. Reduction Rules. The (app), (let) and (tapp) rules are standard. The (handler ) rule reduces a handler application by calling the passed function f under prompt with fresh marker m. The marker acts as a control delimiter [3].

Towards Efficient Adjustment of Effect Rows

177

Fig. 3. Dynamic semantics of system Fpwo

The (promptv ) rule removes the prompt frame if the handled expression is a value. Operation call is divided into two rules: (perform) and (prompt). The (perform) rule prepares the marker m and handler clause f using the evidence vector. In the right-hand side of the (prompt), the handler clause is applied to (type) arguments and wrapped by a lambda to take a resumption. The (prompt) rule captures the resumption λ x : σ2 . prompt m h E[x ] by finding the marker m and applies the operation clause to it, which is instantiated by the (perform) rule.

178

N. Furudono et al.

The (open) rule generates a restrict frame using the effect annotation of the function value. Here, effectof meta-function is used to extract the effect type from the function value, which is either a lambda abstraction, an operation call, or a handler. The effect row of an open expression is used for type checking. The (restrictv ) rule removes the restrict frame if the sub-expression is a value. 3.3

Static Semantics

The static semantics is mutually defined with three relations  , val , and ops in Fig. 4. – Γ  e : σ |  means expression e is typed σ under type environment Γ and contextual effect , i.e., the type of the evidence vector provided for evaluation of e. – Γ val v : σ means value v is typed σ under type environment Γ . Note that if value v can be typed with val relation, then it can be typed with any effect type  with  relation, according to type rule [VAL]. – Γ ops h : σ | l |  means that the sequence of operation clauses h has return type σ and handles effect operation of label l under effect type . We also use well-formedness relation wf and definitional equality of types eq defined in Appendix. Let us now look at the typing rules (Fig. 4). These rules are syntax directed in the sense that the syntax of the expressions determines the applicable type rule. The [VAL] rule types values as expressions with any effect type . The [VAR] rule is usual. The [ABS] rule type checks the body e with the effect annotation  of the lambda abstraction. The [APP] is standard except for the effect type: the operator (e1 ) and operand (e2 ) need to be typed under the contextual effect of entire expression (e1 e2 ). Furthermore, the effect type of the body of the operator also needs to agree with the one of the entire expression. The restriction guarantees that the evidence vector is passed correctly to subexpressions. This may seem too restrictive, but the open construct liberates the restriction. The [BIND] rule is similar to the [APP] rule; both sub-expressions are required to be typed under the contextual effect . The [TABS] and [TAPP] rules are standard except for bound type variables, which cannot have kind lab. The [PERFORM] rule determines the type of the operation call referring to the effect context Σ and the effect row cl σ  |  . The signature of the operation is found in the effect context and the type arguments σ are substituted for the type variables in the argument type τ1 and the result type τ2 . Furthermore, the type arguments σ  from the effect row are applied to τ1 [α: =σ] and τ2 [α: =σ]. As an example, assuming Σ = { exn : { throw : ∀α.string → α }}, we can write λexn  x : string. 1 + perform throw exn  int x as a well-typed function. The operation call in the body is typed with an instance of the [PERFORM] rule as follows. throw : ∀β.string → β ∈ Σ(exn) eq string[β: =int] ≡ string eq β[β: =int] ≡ int x : string val perform throw exn  int : string → exn  int

Towards Efficient Adjustment of Effect Rows

179

Fig. 4. Typing rules of system Fpwo

In this case, cl = exn and σ  is an empty sequence of types. If we replace throw with polythrow string, cl = polyexn and σ  is a singleton sequence string. polythrow : ∀β.(λα.α) → (λα.β) ∈ Σ(polyexn) eq (λα.β)[β: =int] string ≡ int eq (λα.α)[β: =int] string ≡ string x : string val perform polythrow   int : string → polyexn string  int

180

N. Furudono et al.

The [HANDLER] rule is defined for handler expressions. A handler takes a computation of type (() → l |   σ) and handles the effect l . Hence, the effect row of the entire function type is , not l |  . The [OPS] rule determines the effect labels cl σ  , which indicate the handled effect. Each operation clause fi takes an operation argument of type σiin and a resumption of type σiout →  σ. The result type (σ) of the handler is the result type of all resumptions and operation clauses, because handlers in System Fpwo are deep ones. The condition αi ∩ftv(, σ, σ  ) avoids unexpected binding in the type of fi . The arguments of effect constants σ  are derived from the typing of each operation clause. By combining them with the effect constant cl , we derive the effect label cl σ  . The [YIELD] rule requires careful reading. Recall that f is a wrapped handler clause that will be applied to a resumption. The result type of f , which is the result type of the handler clause, must agree with the result type of the resumption. Therefore the two σ  need to agree. The two σ indicate that the input type of f , which is the type of the “result” of the operation call, must agree with the type of the yield expression. Note that the effect type  of yield is not related to the effect  of the operation clause, as the evaluation contexts of yield and prompt (in which f will be evaluated) are different in general. The [PROMPT] rule extends the contextual effect  with the effect label l to type check sub-expression e. The result type of e and that of handler clauses h need to agree, and it becomes the type of the entire prompt expression. The [OPEN] rule opens (make bigger) the effect type to the given effect . The original effect type  must be a closed prefix of the resulting effect type . An effect row 1 is a closed prefix of 2 if and only if 1 consists of labels in a prefix of 2 and ends in  . We discuss the definition of the closed prefix relation in the next section. The [RESTRICT] rule is similar to [OPEN] and allows expression e to be typed under a closed prefix  . 3.4

Effect Rows and Closed Prefix Relation

In this section, we discuss how the type system exploits effect rows to perform type checking against effect handlers and discuss requirement for open and restrict to entail type safety. Recall the typing rule [PERFORM] for operation calls. The conclusion of the rule has an effect row cl σ  |  , which tells us that evaluation of the operation call needs to access a handler of effect label cl σ  . The accessibility of the required handler is guaranteed by the row equivalence rules defined in Fig. 2. For instance, among the two examples below, the first one is correctly rejected due to the inapplicability of [EQ-SWAP], and the second one is accepted as desired. handler h polyexn handler h polyexn

int int

(handler h polyexn (handler h polyexn

string string

(λ_. throw 1)) (λ_. throw ”hello”))

We design the closed prefix relation so that restrict does not increase handlers accessible from the sub-expressions. This is stated as the following property.

Towards Efficient Adjustment of Effect Rows

181

Proposition 1. If .l is defined and    , then  .l is also defined and .l =  .l . It is obvious that the closed prefix relation satisfies this property, but what about other candidates? Initially, we considered an open prefix relation defined as follows.2 l1 , . . ., lk | μ  ? l1 , . . ., lk , lk +1 , . . ., ln | μ  Here, μ is a type variable of kind eff. The open prefix relation leads to loss of type safety, as shown by the following example. (Λμ.λpolyexn int|μ  f : ()→μ (). restrict μ (f ())) polyexn string  λpolyexn string  _.polythrow ”blame!” Here, the example would be accepted with an open prefix relation because μ ? polyexn int | μ  holds. However, the effect annotation polyexn int | μ  indicates that the innermost polyexn handler expects polyexn int, while polythrow raises a string value, causing a type mismatch at runtime! Fortunately, using the closed prefix relation will reject the example and preserve soundness.

4

Open Floating

In this section, we present open floating, a transformation algorithm defined on System Fpwo . We first provide the design of open floating intuitively (Sect. 4.1). We next describe how to implement the idea with defining auxiliary definitions (Sect. 4.2). We then detail the definition of open floating (Sect. 4.3). Lastly, we show an example of the transformation (Sect. 4.4). If the reader is interested in the overall idea of the algorithm instead of the details, the reader may skip the formal sections (Sects. 4.2 and 4.3). 4.1

Design of Algorithm

Open floating is designed under the strategy assign minimum effect for each subexpression. The first half of this section motivates the strategy and the latter half explains a post-process of open floating. In order to reduce open constructs, we first float up open constructs and then collapse redundant ones. We call the first phase open floating, and the second phase the post-process. The aim of open floating is to fuse multiple open constructs into one. We saw an example in Sect. 1 that shows open floating fuses two opens into one restrict. There is one constraint that must be satisfied by open floating: the transformed program is well-typed. The simplicity of the constraint is due to the formalization of open and restrict we saw in Sect. 3. Now the question is: how 2

In the Koka-related literature [9, 19, 20], an effect row is called closed when it ends with  , and open when its tail ends with a type variable. We adopt the naming convention of the above literature.

182

N. Furudono et al.

can we reduce open constructs while satisfying this constraint? Our answer is to assign minimum effect for each sub-expression. Before open floating, open constructs are used to assign the maximum effect type to each expression. Effect adjustments are never inserted in the middle of ASTs. Instead, every expression is assigned a big effect, and if a function call requires effect adjustment, an open construct is inserted to the function call. If adjacent sub-expressions perform the same effect adjustment, we can avoid adjustments at function calls by making the adjustment surround those sub-expressions, which makes the effect types of sub-expressions smaller. Based on this idea, in open floating, we assign the minimum effect type to each subexpression. Unfortunately, the above strategy is naive in that it may insert redundant restrict in a corner case. For instance, the following transformation increases the number of open/restrict constructs from three to four. The problem is the restrict in line 3 of the transformed program. We eliminate it by the post-process. f : () → l1  (),

g : () → l2  (),

λl1 ,l2 ,l3  _. let x = open l1 , l2 , l3  f () in let y = open l1 , l2 , l3  g () in open l1 , l2 , l3  h ()



h : () → l3  () λl1 ,l2 ,l3  _. let x = restrict l1  f () in restrict l2 , l3  ( let y = restrict l2  g () in restrict l3  h ())

It is not hard to see the validity of this removal: the necessary effect adjustment is performed by the restricts on lines 4 and 5. In general, a restrict is redundant if removing it does not force the sub-expressions to use additional open or restrict. We can detect such restricts by checking the sub-expressions of restrictexpressions. 4.2

Organization of Definition

The algorithm consists of three kinds of rules, taking care of expressions, values, and operation clauses. – Γ  e |   e : σ | ϕ – Γ val v  v  : σ – Γ ops h  h  : σ | l |  In this study, proper programs are well-typed and internal-safe expressions. Definition 1. (Internal-free expression, Internal-safe expression) An expression is internal-safe if it is (1) internal-free (contains no prompt or yield expressions) or (2) reduced from an internal-safe expression. Open floating takes a proper program fragment and outputs proper program fragment with an effect requirement ϕ (defined in Sect. 4.3). To define open

Towards Efficient Adjustment of Effect Rows

183

Fig. 5. Effect requirement and auxiliary definitions

floating, we represent the “minimum effect” of an expression as an effect requirement. It is an extension of effect row types with the bottom (least) value ∅. Open floating calculates the effect type of the transformed expression in a bottom-up way with effect requirements. If the effect requirement of the expression is an effect row type, then the expression is assigned that effect type as the output of the algorithm. If the effect requirement of the expression is the bottom value, the expression is assigned an appropriate effect type determined later by the surrounding context. When we assign effect types to expressions, we need to maintain welltypedness of expressions. The auxiliary functions defined in Fig. 5 help us do so. For instance, in the case of a function application, the effect types of the operation, the operand, and the body of operation must be the same. To ensure this, we first calculate the effect requirement of the whole function application using the sup function, and then make the effect types consistent by inserting open constructs using open  and restrict  . Note that open  may produce let bindings to ensure that the function is syntactically a value, which is required by the syntax rule of open. 4.3

The Definition

We define the open floating algorithm in Fig. 6 following the idea described in the previous section. The light propositions are invariants, not side conditions; we write them to clarify the intention of the algorithm design. The transformation is syntax directed, except for applications (e e), in which case both [Open-App] and [App] rules may apply. Rule [Var] simply returns the variable with its type. Rule [Lam] recursively applies the algorithm to the body e. We do not simply return λϕ x : σ1 .e  but return λ restrict  ϕ  e  in order to preserve the type of the lambda abstraction.

184

N. Furudono et al.

Fig. 6. Open floating algorithm

Rule [Val] assigns a null requirement ∅ to value. Rule [App] treats three effect requirements: the requirements of the two sub-expressions (ϕ1 and ϕ2 ) and the effect type of the function body. The rule uses the supremum of these requirements for the result. Rule [Bind] also takes the supremum of the two effect rows.

Towards Efficient Adjustment of Effect Rows

185

Rules [TAbs], [TApp], [Perform], [Handler], and [Ops] simply recursively call the algorithm and propagate the results. Rule [Open-App] processes application of an opened function. It removes open and may assign a smaller effect to the expression. This rule conflicts with the [App] rule, hence we give priority to [Open-App]. Rule [Open-Preserve] recursively calls the algorithm while keeping open. This rule is required for function arguments, for instance. Let us consider the following example. safemap : listint  →   (int → exn  int) →   listint  safemap [1, 2, 3, 4] (open exn  addone) The safemap function expects a function argument of int → exn  int and handles the exception effect. If we want to pass a function of effect   as the argument (addone in this case), we need to open it to make the entire program type check. The last rule [Restrict] ignores the existing restrict. 4.4

Example

Recall from Sect. 4.1 that open floating assigns the minimum effect to each subexpression. Let us observe how this principle is implemented by the definition. Assume we have the following bindings in the type environment, where l1 , l2 , and l3 are distinct effect labels. Γ = f : int → l1 , l2  int, g : int → l2 , l3  int Then, open floating performs the following transformation. λl1 ,l2 ,l3  x : int. let y = (open l1 , l2 , l3  f ) x in let z = (open l1 , l2 , l3  g) y in (open l1 , l2 , l3  g) z



λl1 ,l2 ,l3  x : int. let y = restrict l1 , l2  f x in restrict l2 , l3  ( let z = g y in g z)

First, rules such as [Abs] and [Bind] are applied to the lambda abstraction and let-expressions. The function application (open l1 , l2 , l3  g) z in the last line is processed by rule [Open-App]; it outputs expression g z with effect requirement l2 , l3 . The let-expression in the second-last line is processed by rule [Bind]; it outputs expression let z = g y in g z with effect requirement l2 , l3 . Notice that two opens in this let-expression are eliminated. This is because the effect type of the original program is l1 , l2 , l3 , while the transformed one has effect type l2 , l3 , which agrees with the effect of function g. Focusing our attention on the top-most let-expression, we see that two restricts are inserted. The transformed definition of the let-expression is f x with effect requirement l1 , l2 . The whole let-expression need to require the effect that “satisfies” the requirements of both sub-expressions. This invariant is maintained by the [Bind] rule, which calculates the overall requirement using the sup function and inserts restrict using the restrict  function if needed. Here, both the definition and the body require different effects than the entire let-expression,

186

N. Furudono et al.

Fig. 7. Benchmarking open floating (the ideal case)

hence restricts are inserted (the inner let-expression does not require restrict as there is no gap). Lastly, the [Abs] rule (which is the first rule applied to the program) outputs the overall result of the transformation. Thus, open floating floats up open constructs by determining the effect type of each sub-expression in a bottom-up way.

5

Evaluation

We implemented our open floating algorithm in the Koka compiler [11] and evaluated open floating with artificial programs. The results show that, in the best case, open floating makes programs about 2.5 times faster, while in some cases, it makes programs slower. In this section, we summarize the experiments and discuss what kind of programs are made faster by open floating. 5.1

Ideal Case

Figure 7 includes a fragment of a small benchmark with the execution times. The number of open calls is the sum of open and restrict expressions in the program. As we can see, open floating is very effective here and the enabling open floating improves performance by 2.5×. The execution times are averaged over 3 runs, on an Intel Core i5 at 3 Ghz with 8 GiB memory running macOS 11.6.3, with Koka v2.3.9 extended with open floating. In the program, we use three kinds of effects: read1 , read2 , and exn. The function test-one has the effect read1 ,read2 ,exn, while the other functions use smaller effects. In the body of test-one, the Koka type inferencer inserts the following open calls around the square, square-ask1 , and square-ask2 functions:

Towards Efficient Adjustment of Effect Rows

187

Fig. 8. Benchmark of failure case

let x let y let z x +

= open(square)(1) + . . . + open(square)(1) = open(squareask 1 )() = open(squareask 2 )() y + z

(call open(square)(1) 20 times)

This program contains 22 open calls initially and open floating reduces them to 3 restricts as follows: let x let y let z x +

= restrict( square(1) + . . . + square(1) ) = restrict(squareask 1 ()) = restrict(squareask 2 ()) y + z

(call square(1) 20 times)

Here all individual open calls around each square invocation are floated up to a single restrict, leading to the improved performance. 5.2

Failure Case

However, making a small modification to the program can make open floating less effective. Consider a modified version of the square function in Fig. 7. noinline fun square’( i : int ) : exn int if True then i * i else throw("impossible: ")

We have removed ++ ask1 ().show and changed the effect from ask1 , exn to exn. Just as before, the open floating reduces the number of open calls in the test-one function, but now the program runs slightly slower as shown in Fig. 8. The reason why this happens is that the opened function (square’) has an effect row type of length one. In such case, the Koka runtime already optimizes the use of open by avoiding allocating an explicit evidence vector and directly using the single evidence as is. Since no allocation happens, this can be faster, in particular since the current implementation of the restrict operation is not optimized in a similar fashion yet. Instead, it is implemented combining a lambda . abstraction and an open operation, as restrict e = open(λ_. e)(). We plan to improve the implementation of restrict in which case it should always be beneficial to perform open floating.

188

6

N. Furudono et al.

Future Work

Even though our open floating algorithm is effective, there are still situations where it can be improved. In particular, for certain higher-order programs, such as calls of map and fold, open floating can be improved. Consider the following program. fun map( xs : lista, f : a → e b ) : e listb fun g( x : int) : ask, ndet int fun f() : ask, exn, ndet int ... map(lst, g) ...

The Koka compiler wraps the function g with an open call to make the program type check. This gives us the following expression. map int int ask , exn, ndet  lst (openask , exn, ndet  g) The current open floating algorithm does not change the program due to the rule [Open-Preserve] as we discussed in Sect. 4.3. At runtime, the opened function is applied to each element in the list lst by the map function. It would be better to float the open to surround the entire map call which reduces the number of open calls to one like restrictask , ndet ( map int int ask , ndet  lst g).

7

Related Work

Our work is in the context of passing dynamic evidence vectors at runtime that correspond to the static effect types, as described by Xie and Leijen [20]. The idea of passing dynamic runtime evidence for static properties is not new and is a standard way of implementing qualified types and type classes [5,18]. Here, the evidence takes the form of a dictionary of overloaded operations and corresponds to the qualified type constraints. In our work, open adjusts the runtime evidence vectors, while with type classes instances are used to modify runtime dictionaries. For example, a function with a Show a constraint may call a function with a Show [a] constraint. To call this, the received dictionary for Show a is transformed at runtime to a Show [a] dictionary using the instance Show a => Show [a] declaration. Usually, these “evidence adjustments” are called context reduction and generalized by the entailment relation in the theory of qualified types [6]. Peyton Jones et al. [13] explore the design space of sound context reduction in Haskell. Gaster and Jones [4] present a system for extensible records based on the theory of qualified types. Here, a lacks constraint l /r corresponds to a runtime evidence, providing the offset in the record r where the label l would be inserted. When modifying the record, the evidence is also adjusted at runtime

Towards Efficient Adjustment of Effect Rows

189

to reflect a new offset. For example, if another label is inserted before l , its offset is incremented. A similar mechanism is used in the system of type-indexed rows developed by Shields and Meijer [17]. In all of the above examples, we can imagine transformations similar to open floating that try to minimize the evidence adjustments, although we are not aware of any previous work that addresses this issue specifically. Our formalization of effect row can roughly be understood as an instance of scoped rows discussed by Morris and McKinna [12]. They define a general row theory and row algebra with qualified types. Scoped rows are shown as an instance of them. Closed prefix relation is almost represented as left containment relation. Note that our calculus uses polymorphic effect rows such as ∀μ.l1 , l2 | μ  while scoped rows in [12] do not seem to entail it.

8

Conclusion

In this paper, we formalized open and restrict with their restriction of the closed prefix relation. The formalization clarifies the nature of the Koka language and the constraint of open floating. We also defined the open floating algorithm on the formalized calculus and developed an implementation in Koka. The benchmark shows the effectiveness of open floating and points out room to improve the implementation. Acknowledgement. We acknowledge the reviewers’ efforts put into evaluation of our paper. We also appreciate the FLOPS 2022 reviewers’ feedback on an earlier version of the paper. Lastly, we thank members of Masuhara laboratory. This work was supported in part by JSPS KAKENHI under Grant No. JP19K24339.

Appendix We present the well-formedness relation wf and definitional equality of types eq in Fig. 9. The type rules (Fig. 4) use these relations.

190

N. Furudono et al.

Fig. 9. Well-formedness and definitional equality of types of system Fpwo

References 1. Brachthäuser, J.I., Schuster, P., Ostermann, K.: Lightweight effect polymorphism for handlers. Technical report. University of Tübingen, Germany, Effekt (2020) 2. Dolan, S., White, L., Sivaramakrishnan, K.C., Yallop, J., Madhavapeddy, A.: Effective Concurrency through algebraic effects. In: OCaml Workshop, vol. 13 (2015) 3. Felleisen, M.: The theory and practice of first-class prompts. In: Proceedings of the 15th ACM SIGPLAN-SIGACT Symposium on Principles of Programming Languages, pp. 180–190. POPL ’88. Association for Computing Machinery, New York, NY, USA (1988). 10.1145/73560.73576 4. Gaster, B.R., Jones, M.P.: A Polymorphic Type System for Extensible Records and Variants. NOTTCS-TR-96-3. University of Nottingham (1996)

Towards Efficient Adjustment of Effect Rows

191

5. Jones, M.P.: A theory of qualified types. In: Krieg-Brückner, B. (ed.) ESOP 1992. LNCS, vol. 582, pp. 287–306. Springer, Heidelberg (1992). https://doi.org/10.1007/ 3-540-55253-7_17 6. Jones, M.P.: Simplifying and improving qualified types. In: Proceedings of the 7th International Conference on Functional Programming Languages and Computer Architecture, pp. 160–169. FPCA ’95. La Jolla, California, USA (1995). 10.1145/224164.224198 7. Leijen, D.: Koka: programming with row polymorphic effect types. In: MSFP’14, 5th Workshop on Mathematically Structured Functional Programming (2014). 10.4204/EPTCS.153.8 8. Leijen, D.: Structured asynchrony with algebraic effects. In: Proceedings of the 2nd ACM SIGPLAN International Workshop on Type-Driven Development, TyDe 2017. Oxford, UK, pp. 16–29 (2017). 10.1145/3122975.3122977 9. Leijen, D.: Type directed compilation of row-typed algebraic effects. In: Proceedings of the 44th ACM SIGPLAN Symposium on Principles of Programming Languages, pp. 486–499 (2017) 10. Leijen, D.: Type directed compilation of row-typed algebraic effects. In: Proceedings of the 44th ACM SIGPLAN Symposium on Principles of Programming Languages (POPL’17), Paris, France, pp. 486–499 (2017). 10.1145/3009837.3009872 11. Leijen, D.: Koka repository (2019). https://github.com/koka-lang/koka 12. Morris, J.G., McKinna, J.: Abstracting extensible data types: or, rows by any other name. In: Proceedings of the ACM on Programming Languages, vol. 3 (POPL). ACM New York, NY, USA, pp. 1–28 (2019) 13. Jones, S.P., Jones, M., Meijer, E.: Type classes: an exploration of the design space. In: Haskell Workshop (1997) 14. Plotkin, G.D., Pretnar, M.: Handling algebraic effects. In: Logical Methods in Computer Science, vol. 9, no. 4 (2013). 10.2168/LMCS-9(4:23)2013 15. Pretnar, M., Saleh, A.H., Faes, A., Schrijvers, T.: Efficient compilation of algebraic effects and handlers. CW Reports. Department of Computer Science, KU Leuven, Leuven, Belgium (2017). https://lirias.kuleuven.be/retrieve/472230 16. Saleh, A.H., Karachalias, G., Pretnar, M., Schrijvers, T.: Explicit effect subtyping. In: Ahmed, A. (ed.) ESOP 2018. LNCS, vol. 10801, pp. 327–354. Springer, Cham (2018). https://doi.org/10.1007/978-3-319-89884-1_12 17. Shields, M., Meijer, E.: Type-indexed rows. In: Proceedings of the 28th ACM SIGPLAN-SIGACT Symposium on Principles of Programming Languages, POPL’01, London, United Kingdom, pp. 261–275 (2001) 10.1145/360204.360230 18. Wadler, P., Blott, S.: How to make ad-hoc polymorphism less ad hoc. In: Proceedings of the 16th ACM SIGPLAN-SIGACT Symposium on Principles of Programming Languages, POPL ’89. ACM, Austin, Texas, USA, pp. 60–76 (1989). 10.1145/75277.75283 19. Xie, N., Brachthäuser, J.I., Hillerström, D., Schuster, P., Leijen, D.: Effect handlers, evidently. In: Proceedings of the ACM on Programming Languages, vol. 4 (ICFP). ACM New York, NY, USA, pp. 1–29 (2020) 20. Xie, N., Leijen, D.: Generalized evidence passing for effect handlers: efficient compilation of effect handlers to C. In: Proceedings of the ACM Programming Languages, vol. 5 (ICFP). Association for Computing Machinery, New York, NY, USA (2021). 10.1145/3473576

Author Index

Asai, Kenichi

59

Koopman, Pieter

80

Cong, Youyou 59, 144, 169 Crooijmans, Sjoerd 80

Leijen, Daan 144, 169 Lubbers, Mart 39, 80

Furudono, Naoya

169

Masuhara, Hidehiko

Gibbons, Jeremy

126

Hartmann, Johannes

126

Poulsen, Casper Bach

144, 169 18

Ikemori, Kazuki

144

Shevchenko, Ruslan 1 Steenvoorden, Tim 100

Klijnsma, Tosca

100

van der Rest, Cas

18