The Expression Problem
wiki.c2.com
wiki.c2.com
http://okmij.org/ftp/tagless-final/
"The so-called ``tagless-final'' style is a method of embedding domain-specific languages (DSLs) in a typed functional host language such as Haskell, OCaml, Scala or Coq. It is an alternative to the more familiar embedding as a (generalized) algebraic data type. It is centered around interpreters: Evaluator, compiler, partial evaluator, pretty-printer, multi-pass optimizer are all interpreters of DSL expressions. Doing a tagless-final embedding is literally writing a denotational semantics for the DSL -- in a host programming language rather than on paper."
[0] "Extensibility for the masses", https://www.cs.utexas.edu/~wcook/Drafts/2012/ecoop2012.pdf
[1] "From Object Algebras to Finally Tagless Interpreters", https://oleksandrmanzyuk.wordpress.com/2014/06/18/from-objec...
It should be noted as it is in the abstract of your first reference that these are elaborations on the core idea of Church encodings (and Boehm-Berarducci encodings).
[0] "Who's Afraid of Object Algebras?" https://www.infoq.com/presentations/object-algebras/
- To add a case to a type, the type needs to be abstract, which allows you to declare concrete subtypes. (Julia's subtyping is not OOPy, and it doesn't have FP-style algebraic types.) Then you can add methods to generic functions that are called when passed an argument of your new concrete type, when they need special cases.
- To add operations to an existing type, define a new generic function. You can often do so using just abstract supertypes, and let the compiler optimize to concrete types, but you can also define special-case methods for specific concrete types if you need to.
> Open functions (functions that can be extended with new pattern-matches), open data-types (data types that can be extended with new patterns), and MultiMethods ('open' specialized polymorphic functions with 'open' set of classes), and PredicateDispatching, are all viable approaches.
This is somewhat comparable to OCaml's polymorphic variants, which only solve the expression problem if you give up the completeness check of pattern matching.
[1] yes, I know it does a lot of run-time stuff with things called types, but that's something fundamentally different. Really.
The issue gets stickier when you have a public API and can't make the upgrade in a single patch. What happens if one library defines an AST for a language, there are unknown downstream dependencies that define language tools for various purposes, and you want to change the language to add a new AST node type?
It seems fairly clear that you are revving the language and any tools you have need to be modified to handle the new language construct. They will work fine on v1 of the language but they don't know how to handle v2. This may be a backward-compatible change for programs, but it isn't for tools. They need to handle any possible program, so they need to upgrade or they will break for v2 programs.
But, do we want a lack of v2 support to be a compile error when building the tools? All the tools should work fine with v1. It's only for v2 code that we need the change.
It seems like for smooth migration you need to support multiple, similar versions of the language at the same time. v1 and v2 should be defined simultaneously and tools updated incrementally to support v2.
This gets to be a burden when you are changing the language a lot, though.
Ultimately, the problem boils down to language features that give you the option to update a single location or to update many locations, and each situation has its place (for example, sum types are generally less extensible to downstream packages).
https://homepages.inf.ed.ac.uk/wadler/papers/expression/expr...
Working on GPU code, I feel like I'm always stuck in this problem of 'cross-cutting concerns'. I remember the guy who hired me years ago said during the interview, "we [programmers] have solved the problem of writing code for functionality, but we haven't solved the problem of writing code for performance." And this is what he meant, that cross-cutting concerns are ever present, and a new feature in the code can introduce the need for a complete refactor, or a major top-to-bottom flip in your data structures, etc.
Which languages & language features are good at both solving the expression problem functionally, and can do this with the highest priority being performance?
I also wouldn't mind hearing advice about how to evaluate when to use such features, because most often when I bump into this problem, it's not really due to lack of language features, it's due to having chosen not to use them from the start because there's some development time overhead, and I decided I most likely wouldn't use it, then over time painted myself into a corner.
That itself is a language deficiency, though, right?
Knuth on this topic:
There definitely is something important in the ideas Knuth is expressing abstractly here, and thank you for the link. Perhaps this idea really is percolating slowly to the compiler writers, because at work we have conversations about how to talk to the compiler so that it can better optimize.
That's not what I meant by linking to the Knuth piece. The deficiency lies in the language that doesn't allow you to start off doing the easy thing and then make it "right" through further dialogue, keeping the starting point easy.
I'm pretty sure this is the real intent behind Knuth's focus on literate programming.
For some reason SWE oftentimes hope to achieve some general-AI level tooling and methodologies that would magically let them ignore everything other than than aspect they desire to change.
interface Num<A> {
A add(A l, A r);
A lit(int i);
....
}
class PrintNum implements Num<String> {
String lit(int i){ return Integer.toString(i); }
String add(String l, String r) { return l + " + " + r;}
...
}
class NumInt implements Num<Int> {...}
function A foo<A>(Num<A> f) {
return f.add(f.lit(3), f.lit(4));
}
You can add new functions by extending the interface, you can add new implementations by implementing those interfaces.However you need one class which implements all necessary interfaces for your final type to run the code. This means boilerplate if the interfaces come from different libraries.
You can reduce copying by using adapters which implement some interfaces and forward the rest - but then all adapters have to forward all unrelated methods! This is known as the quadratic instance problem in MTL.
There are lots of attempts to solve this in haskell and currently they are all worse then Tagless final on some axis, usually performance.
Represent a shape as a series of edges and verticies. To compute the are of any shape, sum the lengths of the edges. To find the area, sum the signed areas of the triangles formed by each edge and the origin. Works for all shapes, just define a new one from points and edges.
The point of TFA remains. What abstraction or data structure you select can determine what opertions on the data are easy or hard. This is why we need to study data structures and such - so you can choose the right one for the problem at hand, not so you can pick your favorite and shoehorn every problem into it.
The actual area to learn about here is designing for extension, and software architecture in general, so you can get better at identifying what kinds of changes are going to be simple and what kind will be expensive, so you can tailor your solution to the range of situations you think you're going to face.
The fundamental mistake here is building code that reflects human concepts rather than more fundamental mathematical ones.
Generalization is not the same as extensibility.
Writing general purpose code is harder up front. Extending your code for every new shape leads to complexity problems.
That depends on weather you want to use arc length and certain kinds of areas.
Calculus is a thing too.