implement type inference

Two things out of one mechanism. `_' as a type means "work it out from
the initializer", so (var n _ (strlen s)) stops needing size-t spelled
out; `type-of' hands a macro the type of an expression, so a macro can
dispatch on what it was handed rather than on what was declared. Both
read the same answers from two sides.

Algorithm W's core, intra-procedural, with the extensions C forces:

  - an unknown type, since (include stdio.h) brings in names we never
    parsed. Unification is consistency rather than equality, so
    anything touching an unparsed declaration stops constraining
    instead of rejecting a program that compiled yesterday;
  - the usual arithmetic conversions, since `+' is not a function of
    one type;
  - checking mode for initializers, since #(0 0) has no type of its own
    and takes one from its context. #(T : ...) is the way out of that.

What it wanted on the way:

  - what type a *name* has, which neither the typedef nor the tag
    database recorded. One table serves functions and variables, since
    a function type already has a surface spelling;
  - a scope chain, so a (var c int 9) inside a do ends with the block;
  - form-type, keyed by cons cell, so one form has one type;
  - macros expanded during the walk rather than before it, so type-of
    is answered in the scope the macro was written in.

Closures take the same machinery: a receiver whose type comes from a
call, captures written (name expr) and typed from the expression, and
conversion from a bare function wherever a closure is expected.

type-match grew `_' on the pattern side, since (closure ((int)) int)
and (closure ((float)) int) were separate clauses for one case.
This commit is contained in:
2026-09-29 23:52:08 +03:00
parent 53f92727a5
commit 1f445f0f9b
15 changed files with 1126 additions and 100 deletions

View File

@@ -6,7 +6,11 @@
"From an array: 1 2 3"
"Index evaluated once: 21 i 1"
"Through a struct member: 8"
"Nested: 33")
"Shadowed in a block: 9 then 15"
"Nested: 33"
"Named capture: 7"
"Captured pointer: 11 then 12"
"From a bare fn: 20 42 7")
(return 0)
;;; A closure is a code pointer beside its captures, so what this
@@ -22,6 +26,14 @@
(include stdio.h)
(fn double-it ((n int)) int
(return (* n 2)))
;;; a bare function is a closure that captures nothing, so it converts
;;; wherever one is expected -- here a declared return type
(fn as-closure () (closure ((int)) int)
(return double-it))
(fn make-adder ((n int)) (closure ((int)) int)
(return (closure ((b int)) int (n)
(return (+ n b)))))
@@ -71,10 +83,39 @@
(var h (struct handlers) #((struct handlers) : .on-tick (make-adder 5)))
(printf "Through a struct member: %d\n" ((. h on-tick) 3))
;; a block opens a scope: the inner `add-10' ends with it, and the
;; call after it is the closure again
(do (var add-10 int 9)
(printf "Shadowed in a block: %d then " add-10))
(printf "%d\n" (add-10 5))
;; a closure built inside a closure, capturing that one's capture
(var outer (closure ((int)) int)
(closure ((x int)) int ()
(var inner (closure ((int)) int) (make-adder x))
(return (inner 3))))
(printf "Nested: %d\n" (outer 30))
;; a capture can name what it holds rather than borrow a variable's
;; name, and the expression is evaluated where the closure is written
(var pt (struct handlers))
(var sum-once (closure () int)
(closure () int ((sum (+ 3 4))) (return sum)))
(printf "Named capture: %d\n" (sum-once))
;; capturing a pointer is how by-reference is spelled; the caller owns
;; what it points at
(var counter int 11)
(var peek (closure () int)
(closure () int ((at (& counter))) (return (* at))))
(printf "Captured pointer: %d then " (peek))
(++ counter)
(printf "%d\n" (peek))
;; ...and in an initializer, as an argument, and as a return
(var from-fn (closure ((int)) int) double-it)
(printf "From a bare fn: %d %d %d\n"
(apply-twice from-fn 5)
((as-closure) 21)
(apply-twice (lambda ((n int)) int (return (+ n 1))) 5))
(return 0))

View File

@@ -1,7 +1,8 @@
(input)
(output "direct: 120"
"fac: 120 3628800"
"fib: 55 6765")
"fib: 55 6765"
"applied on the spot: 120")
(return 0)
;;; A fixed point built out of closures, which is the hardest thing to
@@ -52,4 +53,9 @@
(var fib (closure ((int)) int) (fix (& fib-knot)))
(printf "fac: %d %d\n" (fac 5) (fac 10))
(printf "fib: %d %d\n" (fib 10) (fib 20))
;; the combinator's result invoked where it is returned, with no
;; intervening `var' -- the receiver's type is the return type of the
;; signature it came from, which is what the name table records
(printf "applied on the spot: %d\n" ((fix (& fac-knot)) 5))
(return 0))

View File

@@ -0,0 +1,178 @@
(input)
(output "15"
"42 0.25"
"(5, 7)"
"(1, 2)"
"3"
"(5, 7)"
"0 1 4 9 "
"4"
"42"
"<closure of 0: 10>"
"42"
"2 1"
"Hello from Sex!")
(return 0)
;;; Both halves of inference, from the two sides that read the same
;;; answers: `_' in a type means "work it out", and `type-of' hands a
;;; macro the type of an expression.
;;;
;;; This was written as a draft before either existed, to be read
;;; before it was built. It is registered now.
;;;
;;; Two features, one mechanism:
;;;
;;; `_' in a type means "work it out", and
;;; `type-of' hands a macro the type of an expression.
;;;
;;; Both are the same solved constraint store, read from two sides.
(include stdio.h)
(include string.h)
;;; `(include string.h)' is for the C compiler; it tells Sex nothing.
;;; A signature has to be written before `_' can be resolved from a
;;; call to `strlen' -- without one the call's type is `?', and a `_'
;;; that resolves to `?' is an error, not a silent int.
(extern fn strlen ((s (* const char))) size-t)
(struct point ((x int) (y int)))
(fn midpoint ((a (* const struct point)) (b (* const struct point))) (struct point)
(var m (struct point))
;; No `_' here: `m' has no initializer to infer from. Inference fills
;; in a type, it does not invent one.
(= (. m x) (/ (+ (-> a x) (-> b x)) 2))
(= (. m y) (/ (+ (-> a y) (-> b y)) 2))
(return m))
;;; The closure's type is written once, in the signature; `_' reads it
;;; from there at every use.
(fn make-adder ((n int)) (closure ((int)) int)
(return (closure ((b int)) int (n)
(return (+ n b)))))
;;; A macro that asks what it was handed.
;;;
;;; `type-of' returns a *surface* type -- the same spelling the type
;;; database hands to `map-fields' -- so it composes with the
;;; `type-match' that already exists, and dispatch over a user struct
;;; costs nothing extra.
(defmacro (print x)
(type-match (type-of x)
(int `(printf "%d\n" ,x))
(size-t `(printf "%zu\n" ,x))
(double `(printf "%g\n" ,x))
((* const char) `(printf "%s\n" ,x))
;; NOTE: ,x twice -- a macro that duplicates its argument still has
;; to think about evaluating it twice. Inference does not fix that.
((struct point) `(printf "(%d, %d)\n" (. ,x x) (. ,x y)))
;; a closure is a type like any other, so it dispatches like one --
;; and `_' saves a clause per signature
((closure _ int) `(printf "<closure of 0: %d>\n" (,x 0)))
(else (error "print: don't know how to print" (type-of x)))))
;;; The temporary's type is the thing the macro could not write down
;;; before. Either spelling works -- `_' is the lazier one, and it is
;;; inferred in the expansion's own scope.
(defmacro (swap a b)
`(do (var tmp _ ,a)
(= ,a ,b)
(= ,b tmp)))
(pub fn main () int
;; Written out, for contrast with everything below it.
(var greeting (* const char) "Hello from Sex!")
;; size-t, from the signature above -- not int, and not a guess.
(var n _ (strlen greeting))
(printf "%zu\n" n)
;; Literals carry a constraint, not a type: the int-ish one defaults
;; to int, and the mixed division joins to double the way C does.
(var count _ (+ 20 22))
(var half _ (/ 1.0 4))
(printf "%d %g\n" count half)
;; An aggregate initializer has no type of its own, so the type flows
;; in and has to be written. `(var origin _ #(3 4))' is an error --
;; there is nothing to infer from.
(var origin (struct point) #(3 4))
(var corner (struct point) #(7 10))
;; A compound literal is the way out of that rule: the `:' is where an
;; initializer stops needing a type from its context, so `_' has
;; something to read after all.
(var centre _ #((struct point) : 5 7))
(print centre)
;; ...and the same designated, which names fields instead of counting
;; positions.
(var offset _ #((struct point) : .x 1 .y 2))
(print offset)
;; A partial type: "a pointer to something". The something arrives
;; from the initializer. This is why the wildcard lives in the type
;; grammar rather than beside it -- it composes.
(var p (* _) (& origin))
;; Member access reads the same type database the macros do.
(var x _ (-> p x))
(printf "%d\n" x)
;; A call into a function Sex has actually parsed: the return type is
;; the whole answer, and `print' then dispatches on it.
(var mid _ (midpoint (& origin) (& corner)))
(print mid)
;; The loop variable, which is where `_' earns its keep most often.
(for (var i _ 0) (< i 4) (++ i)
(printf "%d " (* i i)))
(printf "\n")
;; Four of something: the element type is fixed by the initializer,
;; the count by the type. Subscripting gives the element type back.
(var squares [_ 4] #(0 1 4 9))
(print [squares 2])
;; A closure's type comes from the signature that produced it, and
;; calling one needs that type and nothing else.
(var add-10 _ (make-adder 10))
(print (add-10 32))
(print add-10)
;; ...including where it is returned, with no name in between.
(print ((make-adder 20) 22))
;; A macro writing a declaration it could not have written before.
(var a _ 1)
(var b _ 2)
(swap a b)
(printf "%d %d\n" a b)
(print greeting)
(return 0))
;;; Open questions this draft raises, to settle before Layer 2 ships:
;;;
;;; 1. SETTLED. `type-match' took `_' on the pattern side, so
;;; `(closure _ int)' above is one clause rather than one per
;;; signature, and `(* _)' and `(¤ _ _)' say "any pointer" and "any
;;; array". A `_' written last takes the rest, since a type's words
;;; are spread and not nested: `(* const char)' is three elements.
;;; Nothing destructures -- a macro body is Scheme and a type is a
;;; list, so `(caddr (type-of x))' reads an array's length.
;;;
;;; 2. SETTLED, allowed. `[_ 4]' against `#(0 1 4 9)' unifies each
;;; element with the hole, so the element type comes from the
;;; literals and the length stays as written -- `[_ 10]' with two
;;; initializers is still ten. Elements that disagree are a type
;;; mismatch. A bare `_' is still refused: `#(0 1 4 9)' has no type
;;; of its own, only elements.
;;;
;;; 3. POSTPONED to the standard library design. `(extern fn strlen
;;; ...)' above duplicates string.h, which is the same bargain every
;;; FFI makes, but it is where "no C header parsing" starts costing
;;; the user something. A `sex/libc' module of prototypes is the
;;; obvious answer and belongs with the rest of the stdlib.

View File

@@ -0,0 +1,83 @@
(input)
(output "literals: 42 3.5 hello"
"calls: 12"
"members: 1 2.5"
"pointers: 1 2.5"
"arrays: 30"
"loop: 0 1 2"
"shadowed: 9 then 42"
"partial: 1 2.5"
"joined: 43.5 84 49 1"
"from elements: 4 9 0")
(return 0)
;;; `_' as a type means "work it out from the initializer". What the
;;; pass can answer comes from declarations -- Sex writes a type at
;;; every binding site -- and from the signature of whatever a call
;;; names. A partial type like `(* _)' is solved by unifying what was
;;; written against what the initializer gives, so only the wildcard
;;; inside the spelling is filled in.
(include stdio.h)
(struct point ((x int) (y float)))
(fn area ((w int) (h int)) int
(return (* w h)))
(pub fn main () int
(var n _ 42)
(var f _ 3.5)
(var s _ "hello")
(printf "literals: %d %g %s\n" n f s)
(var a _ (area 3 4))
(printf "calls: %d\n" a)
(var p (struct point) #((struct point) : .x 1 .y 2.5))
(var px _ (. p x))
(var py _ (. p y))
(printf "members: %d %g\n" px py)
(var pp _ (& p))
(printf "pointers: %d %g\n" (-> pp x) (-> pp y))
(var table (¤ int 3))
(= (¤ table 0) 10)
(= (¤ table 1) 20)
(var first _ (¤ table 0))
(var second _ (¤ table 1))
(printf "arrays: %d\n" (+ first second))
;; a for opens a scope, and its initializer is declared inside it
(printf "loop:")
(for (var i _ 0) (< i 3) (++ i)
(printf " %d" i))
(printf "\n")
;; a block's declarations end with it
(do (var n _ 9)
(printf "shadowed: %d then " n))
(printf "%d\n" n)
;; a wildcard inside a written type: only it is solved
(var pp2 (* _) (& p))
(printf "partial: %d %g\n" (-> pp2 x) (-> pp2 y))
;; C's usual arithmetic conversions, far enough to answer `_'
(var d double 1.5)
(var l long 7)
(var g float 0.5)
(var mixed _ (+ n d))
(var same _ (+ n n))
(var wider _ (+ n l))
(var single _ (+ g g))
(printf "joined: %g %d %ld %g\n" mixed same wider single)
;; a brace initializer has no type of its own, but its elements solve
;; the hole in the array type around it -- and the length stays as
;; written, whether or not every slot is initialized
(var squares (¤ _ 4) #(0 1 4 9))
(var sparse (¤ _ 8) #(0 1))
(printf "from elements: %d %d %d\n" (¤ squares 2) (¤ squares 3) (¤ sparse 7))
(return 0))