Definition
The goal is to define a datatype by cases, where one can add new cases to the datatype and new functions over the datatype, without recompiling existing code, and while retaining static type safety (e.g., no casts).
Logistic Regressor | DecisionTree Regressor | |
---|---|---|
fit() | ||
predict() | ||
---?code=code/src/main/java/interfaces/Algo.java&lang=java&title="The Interface" ---?code=code/src/main/java/implementations/DecisionTreeRegressor.java&lang=java&title="The Implementation" ---?code=code/src/main/java/implementations/LinearRegressor.java&lang=java&title="The Other Implementation"
v0.0.1 | Logistic Regressor | DecisionTree Regressor |
---|---|---|
fit() | @fa[check fa-lime] | @fa[check fa-lime] |
predict() | @fa[check fa-lime] | @fa[check fa-lime] |
v0.0.2 | Logistic Regressor | DecisionTree Regressor | KMeansRegressor |
---|---|---|---|
fit() | @fa[check fa-lime] | @fa[check fa-lime] | |
predict() | @fa[check fa-lime] | @fa[check fa-lime] | |
---?code=code/src/main/java/implementations/KMeansRegressor.java&lang=java&title="Add Another Implementation FTW!"
v0.0.2 | Logistic Regressor | DecisionTree Regressor | KMeansRegressor |
---|---|---|---|
fit() | @fa[check fa-lime] | @fa[check fa-lime] | @fa[check fa-lime] |
predict() | @fa[check fa-lime] | @fa[check fa-lime] | @fa[check fa-lime] |
v0.0.3 | Logistic Regressor | DecisionTree Regressor | KMeansRegressor |
---|---|---|---|
fit() | @fa[check fa-lime] | @fa[check fa-lime] | @fa[check fa-lime] |
predict() | @fa[check fa-lime] | @fa[check fa-lime] | @fa[check fa-lime] |
score() |
package interfaces;
public interface Algo {
void fit();
void predict();
double score();
}
@[6](Requires a reopening of the interface, not always possible) @[8](Requires a reopening of each class and adding new behavior)
v0.0.3 | Logistic Regressor | Decision Tree Regressor | KMeans Regressor |
---|---|---|---|
fit() | @fa[check fa-lime] | @fa[check fa-lime] | @fa[check fa-lime] |
predict() | @fa[check fa-lime] | @fa[check fa-lime] | @fa[check fa-lime] |
score() | @fa[exclamation-triangle fa-orange] | @fa[exclamation-triangle fa-orange] | @fa[exclamation-triangle fa-orange] |
In traditional inheritance based OOP adding new behaviors is difficult
data Algo = LogisticRegressor | DecisionTreeRegressor
| KMeansRegressor
fit :: Algo -> ()
fit LogisticRegressor = {...}
fit DecisionTreeRegressor = {...}
fit KMeansRegressor = {...}
predict :: Algo -> ()
predict LogisticRegressor = {...}
predict DecisionTreeRegressor = {...}
predict KMeansRegressor = {...}
score :: Algo -> Double
...
v0.0.3 | Logistic Regressor | Decision Tree Regressor | KMeansRegressor |
---|---|---|---|
fit() | @fa[check fa-lime] | @fa[check fa-lime] | @fa[check fa-lime] |
predict() | @fa[check fa-lime] | @fa[check fa-lime] | @fa[check fa-lime] |
score() | @fa[check fa-lime] | @fa[check fa-lime] | @fa[check fa-lime] |
v0.0.4 | Logistic Regressor | Decision Tree Regressor | KMeans Regressor | ElasticNet Regressor |
---|---|---|---|---|
fit() | @fa[check fa-lime] | @fa[check fa-lime] | @fa[check fa-lime] | |
predict() | @fa[check fa-lime] | @fa[check fa-lime] | @fa[check fa-lime] | |
score() | @fa[check fa-lime] | @fa[check fa-lime] | @fa[check fa-lime] |
data Algo = LogisticRegressor | DecisionTreeRegressor
| KMeansRegressor | ElasticNetRegressor
fit :: Algo -> ()
--
fit ElasticNetRegressor = {...}
predict :: Algo -> ()
--
predict ElasticNetRegressor = {...}
score :: Algo -> ()
--
score ElasticNetRegressor = {...}
@[1,2,6,10,14](We now have to touch every implementation, also not always possible)
v0.0.4 | Logistic Regressor | Decision Tree Regressor | KMeans Regressor | ElasticNet Regressor |
---|---|---|---|---|
fit() | @fa[check fa-lime] | @fa[check fa-lime] | @fa[check fa-lime] | @fa[exclamation-triangle fa-orange] |
predict() | @fa[check fa-lime] | @fa[check fa-lime] | @fa[check fa-lime] | @fa[exclamation-triangle fa-orange] |
score() | @fa[check fa-lime] | @fa[check fa-lime] | @fa[check fa-lime] | @fa[exclamation-triangle fa-orange] |
- Minimize Recompilation of Existing Code |
- Typesafety - Avoid Casting |
- Avoid Duplication and Modification |
- Visitor Pattern - Java
- Open Classes/Monkey Patching - Ruby
- Multimethods - Clojure
- Canonical Expression Interpreter
- The domain (expressions and interpretations) is the problem writ small
@fa[arrow-down fa-lime] +++
package visitor.interfaces;
import visitor.impl.expressions.BinaryAddition;
import visitor.impl.expressions.Literal;
public interface Visitor<T> {
T visit(Literal l);
T visit(BinaryAddition b);
}
@[7-8](a visitor will need to visit every type of expression in the hierarchy)
+++
package visitor.interfaces;
public interface Visitable {
/* The accept method on a visitable i.e. a type on the expression heirarchy,
is the secret sauce it allows an independent operation defined elsewhere
"access" to the type to perform the action */
<R> R accept(Visitor<R> v);
@[7](Many Bracket, Such R!)
+++
package visitor.impl.expressions;
import visitor.interfaces.Visitable;
import visitor.interfaces.Visitor;
public class Literal implements Visitable {
private int value;
public int getValue() {
return value;
}
public Literal(int v) {
this.value = v;
}
@Override
public <T> T accept(Visitor<T> v) {
return v.visit(this);
}
}
@[17-21](Mostly POJO boilerplate sans the accept
method, which takes this
providing the visitor access to the object)
+++
package visitor.impl.visitors;
import visitor.impl.expressions.BinaryAddition;
import visitor.impl.expressions.BinaryMultiplication;
import visitor.impl.expressions.Literal;
import visitor.interfaces.Visitor;
public class ShowVisitor implements Visitor<Void> {
@Override
public Void visit(Literal l) {
System.out.print(l.getValue());
return null;
}
@Override
public Void visit(BinaryAddition b) {
System.out.print("(");
b.getLhs().accept(this);
System.out.print(" + ");
b.getRhs().accept(this);
System.out.print(")");
return null;
}
}
@[8](Sorry for the Void
type hackery)
@[19, 21](Note the call to accept
, which will allow the visitor to "recursively" visit sub-expressions)
+++
package visitor;
import visitor.impl.expressions.BinaryAddition;
import visitor.impl.expressions.BinaryMultiplication;
import visitor.impl.expressions.Literal;
import visitor.impl.visitors.EvaluateVisitor;
import visitor.impl.visitors.ShowVisitor;
public class RunVisitor {
public static void main(String[] args) {
Literal l = new Literal(5);
Literal r = new Literal(10);
BinaryAddition b = new BinaryAddition(l, r);
b.accept(new ShowVisitor());
System.out.print(" = " + b.accept(new EvaluateVisitor()));
}
}
@[15,16](Would probably wrap up in helper methods but yes, now you give a visitor of your choosing to the expression)
+++?code=code/src/main/java/visitor/impl/expressions/BinaryMultiplication.java&lang=java&title="Adding a type, still pretty easy...."
+++?code=code/src/main/java/visitor/interfaces/Visitor.java&lang=java&title="But now..." @[10](I do need to open the visitor interface)
+++?code=code/src/main/java/visitor/impl/visitors/ShowVisitor.java&lang=java&title="Showing for Binary Multiplication" @[28-38](And define the operation in all the visitors for new type)
+++?code=code/src/main/java/visitor/impl/visitors/EvaluateVisitor.java&lang=java&title="Adding a whole new kind of behavior...free" @[9](This can be added and defined for any type currently in the heirarchy anywhere you want)
@fa[arrow-down fa-lime]
+++
class Literal < Struct.new(:value)
def show
print value
end
end
class BinaryAddition < Struct.new(:lhs, :rhs)
def show
lhs.show
print ' + '
rhs.show
end
end
@[1-13](Disclaimer: These are the first lines of Ruby I have ever written) +++
class Negative < Struct.new(:expression)
def show
print ' - ('
expression.show
print ')'
end
end
@[1](Yup, not a problem...) +++
require_relative '../Literal'
-- Defined anywhere
module ExpressionExtensions
refine Literal do
def evaluate
value
end
end
refine BinaryExpression do
def evaluate
lhs.evaluate + rhs.evaluate
end
end
refine Negative do
def evaluate
-expression.evaluate
end
end
@[4](This scoped monkeypatching with refinements is a safer version of the open class pattern, but potentially still brittle)
+++
require_relative 'modules/expression_extensions'
require_relative 'Literal'
class Run
using ExpressionExtensions
def go
c = Literal.new(10)
c.show
l = Literal.new(25)
r = Literal.new(100)
print "\n"
b = BinaryExpression.new(l, r)
b.show
print ' = '
b.evaluate
end
end
Run.new.go
@[5](The only additional requirement is the use of a using
)
+++
- This is extremely extensible |
- Minimal boilerplate |
+++
-
Easy, it seems, to abuse |
-
Leaky: methods already called at the definition site are not refined at the call site |
-
something...programming without types...something, something...science without units |
@fa[arrow-down fa-lime] +++
Some LISPs have them but they are particularly well executed in Clojure
; Data
(defrecord Literal [value])
(defrecord BinaryAddition [lhs rhs])
@[2](defrecord
is a runtime macro to create a simple data class or value class, struct-like)
+++
Each implementation dispatches on the type and performs the operation (close to Haskell here)
; Here 'class' refers to the built-in function clojure.core/class
(defmulti show class)
; the macro has the name "method" that should tip you off about
; what this is when the jvm gets involved
(defmethod show Literal
[lit] (str (:value lit)))
(defmethod show BinaryAddition
[ba] (clojure.string/join " + " [(show (:lhs ba))
(show (:rhs ba))]))
; are you feeling these parens yet!!!
@[6, 9](if show exists I sits ...sorry "dispatch")
+++
(defmulti evaluate class)
(defmethod evaluate Literal
[lit] (:value lit))
(defmethod evaluate BinaryAddition
[ba] (+ (evaluate (:lhs ba))
(evaluate (:rhs ba))))
@[1](Can be defined anywhere types and behaviors are orthogonal to each other in Clojure)
+++
(defrecord Negative [expr])
; Behavior for Data, we do not need to redefine `defmulti`
(defmethod show Negative
[n] (str "-(" (show (:expr n)) ")")
(defmethod evaluate Negative
[n] (* -1 (evaluate (:expr n))))
@[1-8](We can add new ops without touching any existing code. We can also add new types without touching any existing code)
+++
; on the repl
user=> (show (->Literal 1.3))
"1.3"
user=> (evaluate (->BinaryAddition (->Literal 1.4) (->Literal 1.6)))
3.0
user=> (evaluate (->Negative (->Literal 5)))
-5
user=> (show (->Negative (->BinaryAddition (->Literal 10) (->Literal 10))))
"-(10 + 10)"
@[2](The "->" is the defrecord
parameter constructor syntax/there is a map syntax too!)
+++
This is easy in Clojure and other LISPs that have multimethods because of open methods, that is method definitions are not tied to a type/class/file they operate upon the type, thus they do not require awareness of the implementation or access to the code
- In all seriousness, Clojure is a elegant language inelegantly tooled |
- Implicits
@fa[arrow-down fa-lime]
+++
- Line 187 of the Predef Object, a helpful comment!
@inline def implicitly[T](implicit e: T) = e // for summoning implicit values from the nether world
+++
object App {
/******* implicit conversion ********/
class Result(v: Integer) {
def get: Integer = v
//explicitly using java.lang.Integer
}
implicit class ResultOps(r: Result) {
// Type "enrichment" or in a less woke time "Pimping"
def getOpt: Option[Integer] = Option(r.get)
}
def consume(bad: Result) = {
val better = bad.getOpt
better
}
consume(new Result(null)).fold(ifEmpty = 2)(_ + 4) // 2
consume(new Result(10)).fold(ifEmpty = 2)(_ + 4) // 14
/******* implicit parameter ********/
implicit val nullConverter: Result => Integer =
x => if (x.get == null) 2 else x.get
def consumeRaw(x: Result)(implicit converter: Result => Integer): Integer = {
converter(x)
}
consumeRaw(new Result(null)) // 2
consumeRaw(new Result(10)) // 10
}
+++
- Two very closely related features implicit parameters and implicit conversions
- Both invoke machinery to resolve type errors discovered by attempting to find supplemental info in scope
+++
- When a method fails to have all the required arguments passed to it
+++
- When a type fails to have the right shape or doesn't match the expected type
+++
- One phase of the compiler "the namer" assigns symbols to the parsed syntax tree created in the previous phase called "the parser" |
- "the typer" (where like most of the heavy lifting happens) checks to see if those symbols "type checks" |
- This is where implicits are summoned and wrap the symbol or type in a way that it allows it to typecheck |
+++
- An implicit needs to be brought into scope only once |
- The tradeoff is that there must be exactly one implicit of the right type into that scope |
+++
- A pattern that consists of the use of one or more parameterized traits, concrete instances of that trait and the appropriate implicits
+++
+++
trait Exp
case class Literal(value: Int) extends Exp
case class BinaryAddition[A <: Exp, B <: Exp](left: A, right: B) extends Exp
+++
// Typeclass Behavior Trait
trait Eval[A] {
def eval(a: A): Int
}
object Evaluator {
// Typeclasses instances
implicit def LiteralEval =
new Eval[Literal] {
override def eval(a: Literal) = a.value
}
implicit def BinaryAdditionEval[A <: Exp, B <: Exp](implicit evA: Eval[A], evB: Eval[B]) =
new Eval[BinaryAddition[A, B]] {
override def eval(expr: BinaryAddition[A, B]) = evA.eval(expr.left) + evB.eval(expr.right)
}
}
@[8, 13](Lift each datatype you would like to exhibit a particular behavior into the typeclass context) @[13](We need a second level implicit to find evidence that A and B can be 'evaled')
+++
import Evaluator._ //bring implicits into scope
val l1 = Literal(5)
val l2 = Literal(10)
val add = BinaryAddition(l1, l2)
LiteralEval.eval(l1) // okay
BinaryAdditionEval[Literal, Literal].eval(add) // eh....not great!
@[7](The call site is a little busy...we'll fix that shortly)
+++
case class Negative[A <: Exp](expr: A) extends Exp
object NegativeEvaluator {
implicit def NegativeEval[A <: Exp](implicit ev: Eval[A]) = new Eval[Negative[A]] {
override def eval(a: Negative[A]): Int = -ev.eval(a.expr)
}
}
import NegativeEvaluator._ //bring implicits into scope
val neg = Negative(add)
NegativeEval[BinaryAddition[Literal, Literal]].eval(neg) // this is getting out of hand
+++
trait Show[A] {
def show(a: A): String
}
object Shower {
implicit def LiteralShower = new Show[Literal] {
override def show(a: Literal): String = s"lit ${a.value}"
}
implicit def BinaryAdditionShower[A <: Exp, B <: Exp](implicit evA: Show[A], evB: Show[B]) =
new Show[BinaryAddition[A, B]] {
override def show(a: BinaryAddition[A, B]) =
"(" + evA.show(a.left) + " + " + evB.show(a.right) + ")"
}
implicit def NegativeShower[A <: Exp](implicit ev: Show[A]) =
new Show[Negative[A]] {
override def show(a: Negative[A]) = "-" + "(" + ev.show(a.expr) + ")"
}
}
import Shower._ //bring implicits into scope
LiteralShower.show(l1)
NegativeShower[BinaryAddition[Literal, Literal]].show(neg)
@[24](Let's fix this now...)
+++
object LowerPriorityImplicits {
//Enriching the Literal type with new 'syntax'
implicit class LiteralSyntax(lit: Literal) {
//def a method which performs the behavior
def evaluate(implicit ev: Eval[Literal]) = {
ev.eval(lit)
}
def show(implicit ev: Show[Literal]) = {
ev.show(lit)
}
}
implicit class BinaryAdditionSyntax[A <: Exp, B <: Exp](ba: BinaryAddition[A, B]) {
def evaluate(implicit ev: Eval[BinaryAddition[A, B]]) = {
ev.eval(ba)
}
def show(implicit ev: Show[BinaryAddition[A, B]]) = {
ev.show(ba)
}
}
implicit class NegativeSyntax[A <: Exp](neg: Negative[A]) {
def evaluate(implicit ev: Eval[Negative[A]]) = {
ev.eval(neg)
}
def show(implicit ev: Show[Negative[A]]) = {
ev.show(neg)
}
}
}
+++
import LowerPriorityImplicits._ //bring into scope
l1.evaluate // LiteralEval.eval(l1)
add.evaluate // BinaryAddition[Literal, Literal].eval(neg)
add.show // BinaryAdditionShower[BinaryAddition[Literal, Literal]].show(add)
neg.show // NegativeShower[BinaryAddition[Literal, Literal]].show(neg)
@[2, 5](Syntax classes, implicitly "enriching" the type allow us to simply dot off the object)
- Tagless Final |
- Object Algebras (Partially working impl in repo) |
- Interpreter Pattern and the Free Monad |
- Finally Solving the Expression Problem (YouTube)
- The Expression Problem in Ruby
- The Expression Problem and its Solutions
- Independently Extensible Solutions to the Expression Problem (Whitepaper)
- Extensibility for the Masses (Whitepaper)
- Implicit Class: The Machinery Behind the Semantics (YouTube)
- Implicits and Type Classes in Scala
- Type Classes in Scala at LX Scala (YouTube)
- Typeclass Traits Proposal (Martin Odersky)
Express Yourself!