Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Type checked units in zig by run-time operator-overloading and comptime book keeping #3002

Closed
ghost opened this issue Aug 3, 2019 · 2 comments
Labels
proposal This issue suggests modifications. If it also has the "accepted" label then it is planned.
Milestone

Comments

@ghost
Copy link

ghost commented Aug 3, 2019

This proposal is probably not suitable for zig (very scenario specific type checking earned at the cost of more language complexity), but I will leave it for reference.

Start with a basic type (struct/primitive hybrid) BaseUnit, that holds a mutable value (f64) and a "comptime" mutable dictionary of unit-names (enum value) to exponents (signed int).

BaseUnit = {value: f64, exp-dict : []int} 
// where index of exp-dict can be translated to a unit name
// like [meter], [USD], etc, with an enum definition elsewhere

Then, let BaseUnit be supported by binary operators (acting on the f64 field) at runtime, while at comptime also the exponent-dictionary is updated and checked.

E.g {2.1, [0,3,1]} * {0.5, [0,-3,2] } becomes {1.05, [0,0,3] }, and {3.0, [1,0,2] } / {2.0, [-1,0,2] } becomes {1.5, [2,0,0] }. Addition and subtraction leaves the exp-dict unchanged if the exp-dicts of the two values are the same. If they don't match, you'll get a compile error.

For type checks in code, now introduce named types e.g UnitMeter, UnitSecond, that are the same as BaseUnit except with a fixed exponent-dictionary.

// examples below
UnitSecond = { 4.3, [s=1,  m=0, kg=0, ...] }
UnitMeter =  {-3.2, [s=0,  m=1, kg=0, ...] }
UnitNewton = {  10, [s=-2, m=1, kg=1, ...] }
..etc

Usage in code, getting some type safety for units:

const length = UnitMeter(42.0);

 // compile time error. exponent dictionaries not matching
const massBad = UnitKg(32) + UnitMeter(5);

// addition and subtraction must be between units with the exact same exponent-dictionary.
const mass = UnitKg(80.0) + UnitKg(3.0); 

// can multiply units of different type, as long as they are a comptime number or a form of BaseUnit.
const seconds2 = 1*UnitSecond(1.0)*UnitSecond(3.33); 

// compile time error. exponent-dictionaries not matching
const nBad : UnitNewton = length*length; 
const nOk : UnitNewton = (length*mass)/seconds2; // accepted

It's possible to create something like this with normal structs, but the interesting thing here is the idea of having comptime checks on a subset of instance variables for a given type. In this example, the exponent-dictionaries are fully redundant at runtime, so there is no reason to have any performance impact compared to using primitive f64.

Related: #1595 #2953

@daurnimator daurnimator added the proposal This issue suggests modifications. If it also has the "accepted" label then it is planned. label Aug 4, 2019
@CurtisFenner
Copy link

There have been proposals for operator overloading before, so I will set that aside.

How exactly do you think the comptime bookkeeping should work? (I'm going to simplify to a much smaller setting than you propose: compile time checking that + is only done on like units.) I think you want to enable code that looks something like this:

const std = @import("std");
const assert = std.debug.assert;

const Measure = struct {
    amount: f64,
    comptime unit: []const u8,

    pub fn add(comptime unit: []const u8, a: Measure, b: Measure) Measure {
        comptime assert(std.mem.eql(u8, unit, a.unit));
        comptime assert(std.mem.eql(u8, unit, b.unit));
        return Measure{ .amount = a.amount + b.amount, .unit = unit };
    }
};

test "inch + inch" {
    var a = Measure{ .amount = 1, .unit = "inch"[0..] };
    var b = Measure{ .amount = 2, .unit = "inch"[0..] };

    var c = Measure.add("inch", a, b);
}

Zig doesn't currently allow you to mark fields comptime; this code marks the unit field on Measure comptime so that the comptime assert(std.mem.eql(u8, unit, a.unit)); can be run. (Without this change, you would get an error "unable to evaluate constant expression a.unit")

But how could this actually be done?

If we have a non-comptime function that returns a Measure, we would have to evaluate "enough" of the function to be convinced which comptime []const u8 is always set in the unit field in the returned struct. This is a fairly complex analysis, but I think it (conservatively) is possible. (Repeatedly evaluate dependent statements/expressions/conditions, failing if anything that "matters"/affects the result, cannot be done at comptime).

What if we want to return a slice []Measure? The easy thing to say it that this is not allowed, but I think that would make a very complex feature not very useful. It's also tempting to say that all must be initialized to the same unit (allowing you to do a similar analysis as before), but that also greatly weakens the power of this feature; it also doesn't solve, for example, a [][]Measure, or a struct {a: Measure, b: Measure}.

In the limit, something like this seems like it should be allowed:

fn stripes(comptime units: []const []const u8, n: usize, allocator: *std.mem.Allocator) ![]Measure {
    var out = try allocator.alloc(Measure, n);
    for (out) |*v, i| {
        out[i] = Measure{
            .amount = @intToFloat(f64, i),
            .unit = units[i % units.len],
        };
    }
}

however the "type" of the result of stripes is now extremely complicated, and devising a way to express it in the Zig source-code would balloon the complexity of Zig far beyond a regular C replacement. Accepting that the unit field of a Measure is dependent on the "path" to that Measure value means that simple evaluate-at-compile-time is no longer sufficient to determine the value of units at comptime.

This proposal is very scant on details, so I can't know if I've taken this in completely the wrong direction.

@ghost
Copy link
Author

ghost commented Aug 5, 2019

How exactly do you think the comptime bookkeeping should work?

const Measure = struct{
  value: f64,
  exponents: [7]i8, // this "mask" is used to check and update Measure instances before and after operations
//notice it's an array of signed integers, not unsigned.
};

It might indeed not be possible to keep track of an instance field at compile time, a necessity for this proposal. If some form of subtyping was possible, then MeasureMeter could be passed into a function accepting Measure, and for the subtype MeasureMeter, the exponent array could always be the same and thus determined at compile time.

This proposal is very scant on details, so I can't know if I've taken this in completely the wrong direction.

This proposal is basically just a spin-off from the discussion in issue #1595, especially regarding a comment on how operations can sometimes yield new units.

I hope everything can become more clear with this additional example: Purely runtime version of this type checking concept in a gist with runnable code (zig test).

Excerpt:

// units are represented by exponents.
// simple units, index corresponds to SI system.
const exponentsMeter=  [7]i8{0,1,0,0,0,0,0}; // [m]
const exponentsSecond = [7]i8{1,0,0,0,0,0,0}; // [s]
const exponentsKg = [7]i8{0,0,1,0,0,0,0}; // [kg]
//..etc

// more complex units 
const exponentsSpeed = [7]i8{-1,1,0,0,0,0,0}; // [m/s]
const exponentsNewton = [7]i8{-2,1,1,0,0,0,0}; // [kg*m/s^2]
const exponentsWatt = [7]i8{-3,2,1,0,0,0,0}; // [kg*m^2/s^3]
const exponentsTesla = [7]i8{-2,2,1,-2,0,0,0}; // [kg*m^2/(s^2*A^2)]

// ....

test "example" {
  
  var distance = UnitMeter.init(10).toMeasure();
  var time = UnitS.init(2).toMeasure();

  UnitOps.div(&distance,time);
  var tmp = distance;

  assert(std.mem.eql(i8,tmp.exponents,exponentsSpeed));

  // var kg = UnitKg.initFromMeasure(tmp); // runtime error

}

@ghost ghost mentioned this issue Aug 7, 2019
@andrewrk andrewrk added this to the 0.6.0 milestone Aug 11, 2019
@ghost ghost mentioned this issue Jan 26, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
proposal This issue suggests modifications. If it also has the "accepted" label then it is planned.
Projects
None yet
Development

No branches or pull requests

3 participants