Preamble
So i've noticed that a lot of functional / jitted languages (eg julia, numba jit for python) have virtual methods attached to "functions" rather than classes, and those "functions" can be dispatched based on types of several arguments. example in julia:
function f(x::Int)
println("x::Int = $x")
end
function f(x::AbstractFloat)
println("x::Float = $x")
end
function f(x::Int, y::AbstractFloat)
println("x::Int = $x ; y::Float=y")
end
f(5) # prints "x::Int = 5"
f(6.5) # prints "x::Float = 6.5"
f(7, 8.5) # prints "x::Int = 7 ; y::Float = 8.5"
f(9.5, 10) # throws a MethodError
I want to understand how to implement a dispatching system like this in C. For a function with exactly 1 argument and exactly 0 return values this is a trivial task:
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
const int T_int = 1;
const int T_float = 2;
// idk any shorter way of type punning
static inline uintptr_t f2i(float x){union {uintptr_t i; float f;} fi; fi.f = x; return fi.i;}
static inline float i2f(uintptr_t x){union {uintptr_t i; float f;} fi; fi.i = x; return fi.f;}
void f_int(uintptr_t x){printf("int x = %d\n", (int) x);}
void f_float(uintptr_t x) {printf("float x = %f\n", i2f(x));}
typedef void (*Fn1) (uintptr_t);
typedef struct {int T; Fn1 fn;} Method1;
typedef struct {int len; Method1*methods; } Dispatch1;
void call1(Dispatch1 disp, int T, uintptr_t x){
for (int i = 0; i < disp.len; i++) {
if (T == disp.methods[i].T) {
return disp.methods[i].fn(x);
}}
printf("dispatch for type tag %d failed\n", T); exit(1);
}
int main() {
Dispatch1 f = {2, calloc(2, sizeof(Method1))};
f.methods[0] = (Method1) {T_int, (Fn1) f_int};
f.methods[1] = (Method1) {T_float, (Fn1) f_float};
call1(f, T_int, (uintptr_t) 5); // prints "int x = 5"
call1(f, T_float, f2i(6.5)); // prints "float x = 6.50000"
call1(f, 10, (uintptr_t) 5); // prints "dispatch for type tag 10 failed", exits
}
The question:
is there a relatively simple way to implement in C a similar dispatch for arbitrary number of arguments?
if no, is there a relatively simple way to implement this for functions with, say, 0-3 arguments and 0-1 return values?
Note: i know, that this is inefficient, dumb, etc, i don't care.
Edit1: i don't want generic programming in C, i want something like a vtable, but for dispatching on multiple arguments.
Edit2: the context of why i'm doing is, i want to implement something like a Lisp interpreter, but with typecheking capabilities. i want to understand how this feature works without sifting through julia / LLVM codebase.
What i don't understand, is how to compresses function signature, so that each vtable entry would be relatively small and fast to check against argument types.
So after some thinking i came up with this system. it's not exactly idiomatic form pov of C programming, but it does exactly what i want and seems to work well with limited number of arguments (0-7 arguments, 0-1 return value). Key insights:
limitations:
below is the prototype i've ended up with for now:
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
// type system. for now only numeric types,
// but can be extended to arrays, strings & custom types
typedef union TPun TPun;
typedef struct Obj Obj;
union TPun{ // for type punning
uintptr_t Any; void* P; Obj (*Fn)(Obj *arg7);
uint8_t U8; uint16_t U16; uint32_t U32; uint64_t U64;
int8_t I8; int16_t I16; int32_t I32; int64_t I64;
float F32; double F64;
};
struct Obj {uint16_t T; TPun V;};
typedef enum {
T_Unit = 0, T_Any=1, T_P = 2, T_Fn=3,
T_U8=4, T_U16=5, T_U32=6, T_U64=7,
T_I8=8, T_I16=9, T_I32=10, T_I64=11,
T_F32=12, T_F64=13,
} TBase;
const char * TBaseNames[14] = {
"Unit", "Any", "Ptr", "Fn",
"U8", "U16", "U32", "U64",
"I8", "I16", "I32", "I64",
"F32", "F64",
};
// dispatch system
typedef struct {uint16_t retT; uint16_t aT[7]; void* fn;} Method7;
typedef struct {uint16_t len; uint16_t cap; Method7*methods;} Dispatch7;
// Dispatch is a dynamic array of signatures + pointers
int find_method7(Dispatch7 *disp, uint16_t sig[8]){
for (int i=0; i<(int) disp->len; i++) {
Method7*m = &disp->methods[i];
uint16_t* msig = (uint16_t*) m;
int sig_match = 1;
for (int a = 0; a<8; a++) {
sig_match &= (sig[a] == T_Any) | (msig[a] == T_Any) | (msig[a] == sig[a]);
// any type is expected | returns untyped | types match
}
if (sig_match) {return i;}
}
return -1;
}
int put_method7(Dispatch7 *disp, Method7*meth){
int i = find_method7(disp, (uint16_t*) meth);
if (i != -1) { // substitute
disp->methods[i] = *meth;
return i;
} else { // append
if ((disp->cap-disp->len)==0) {// realloc
uint32_t newcap;
if (disp->cap == 0) newcap=1;
else if (disp->cap==1) newcap=4;
else newcap=disp->cap+4;
disp->methods = reallocarray(disp->methods, disp->cap+4, sizeof(Method7));
assert(disp->methods && "don't forget to buy some RAM");
disp->cap = newcap;
} // append
i = disp->len;
disp->len++;
disp->methods[i] = *meth;
return i;
}
}
int call7(Dispatch7*disp, Obj args[8]){
uint16_t sig[8];
for (int i=0; i<8; i++) {sig[i] = args[i].T;}
int i = find_method7(disp, sig);
if (i == -1) return 1;
TPun fn; fn.Fn = disp->methods[i].fn;
args[0] = fn.Fn(&args[1]);
return 0;
}
void clear_args7(Obj args[8]){
for (int i = 0; i<8; i++){
args[i].T = T_Unit;//0
args[i].V.Any = 0;
}
args[0].T = T_Any; // by default no expectation of return type,
// if particular type should be matched, it must be set explicitly
}
// example functions
Obj f_int(Obj *args){
printf("int x = %ld\n", args[0].V.I64);
return ((Obj) {T_Unit,{.Any=0}});
}
Obj int_f_int(Obj *args){
printf("int x = %ld ; return int x = %ld \n", args[0].V.I64, args[0].V.I64+5);
return (Obj) {T_I64,{.I64=args[0].V.I64+5}}; // to test returns
}
Obj f_float(Obj *args) {
printf("float x = %f\n", args[0].V.F32);
return (Obj) {T_Unit,{.Any=0}};
}
Obj f_int_float(Obj *args) {
printf("int x = %ld ; float y = %f\n", args[0].V.I64, args[1].V.F32);
return (Obj) {T_Unit,{.Any=0}};
}
Method7 ms[4] = {
{0, T_I64, 0,0,0,0,0,0, f_int},
{T_I64, T_I64, 0,0,0,0,0,0, int_f_int},
{0, T_F32, 0,0,0,0,0,0, f_float},
{0, T_I64, T_F32, 0,0,0,0,0, f_int_float},
};
Dispatch7 f = {4, 4, ms};
int main(){
Obj args[8];
clear_args7(args);
args[1] = (Obj) {T_I64,{.I64=5}};
call7(&f, args); // int x = 5
assert((args[0].T == T_Unit) && "void_f_int should be called");
clear_args7(args);
args[0].T = T_I64;
args[1] = (Obj) {T_I64,{.I64=5}};
call7(&f, args); // int x = 5
assert((args[0].T == T_I64) && "inf_f_int should be called");
assert((args[0].V.I64 == 10) && "int_f_int should return 10");
clear_args7(args);
args[1] = (Obj) {T_F32,{.F32=6.5}};
call7(&f, args); // float y = 6.50000
clear_args7(args);
args[1] = (Obj) {T_I64, {.I64=7}};
args[2] = (Obj) {T_F32,{.F32=8.5}};
call7(&f, args); // int x = 7 ; float y = 8.50000
clear_args7(args);
args[1] = (Obj) {T_F32,{.F32=9.5}};
args[2] = (Obj) {T_I64, {.I64=10}};
int i = call7(&f, args);
assert((i != 0) && "should fail");
}