ziglang / zig

General-purpose programming language and toolchain for maintaining robust, optimal, and reusable software.
https://ziglang.org
MIT License
33.79k stars 2.47k forks source link

Operator Overloading #871

Closed nicolasessisbreton closed 6 years ago

nicolasessisbreton commented 6 years ago

I know operator overloading is considered out of Zig scope. But no issue seems to make the following case so here it is.

Operator overloading matter in scientific computing

Consider writing polynomial addition with function

p = 2*x*y + 3*z + 5y - 8*x;

assume x, y and z are some other complex polynomials. With functions, this becomes

p = sub(add(add(mul(2, mul(x,y)), mul(3,z)), mul(5,y)), mul(8,y)) Is the intent clear in the previous line? Isn't forcing the previous line on user breaking Zig simplicity principles?

Zig promises to make scientific computing easier and fun

When writing numerical code in C++, memory leak is a pain. With Zig focus on no 'undefined behavior', writing fast numerical can be made much easier. C++ is currently a big entry barrier in high performance numerical computing.

Why 'printf' and not '+'

Zig brings a new paradigm where function previously hidden in the compiler internal are now in userland. Why limit this paradigm to standard function call?

Zig offers an all encompassing approach that offers a build system and a package manager. There are many domain where operator overloading is crucial. For example, in Fortran '+' is overloaded in the compiler to support vector addition. If Zig doesn't offer operator overloading, users will either:

These two solutions break many Zig principles.

bnoordhuis commented 6 years ago

How should this integrate with error handling and resource management?

  1. Error handling: how do you write x = a + b + c when + is overloaded and fallible?

    Example: fn +(a: &const BigInt, b: &const BigInt) !BigInt - i.e., either a new BigInt or an error like error.OutOfMemory.

  2. Resource management: zig doesn't have destructors or linear or affine types. What cleans up the intermediate values in x = a + b + c?

nicolasessisbreton commented 6 years ago

1. Error handling

Error aware overloaded operators can either:

The choice between these should be left to the user.

2. Resource management

Resource management should be left to the creativity of users. The two extremes are:

Additional Comments

The points raised by @bnoordhuis are good. If we look at a biased history of programming languages:

Supporting operator overloading in Zig is one step toward this history.

Will there be too many ways to do the same thing?

This is a non-issue, because we are talking about userland. Zig provides one way to overload operators, no suprise. The rest will be inevitably wild, it's normal and needed.

logzero commented 6 years ago

x = a + b + c should be equivalent to x = add(add(a, b), c)

How does zig deal with error handling and resource management in that case?

PS: I am new to zig and I am also one of those keen on operator overloading. ;)

tgschultz commented 6 years ago

My personal feeling is that operator overloading is too far into the "hidden behavior" territory to mesh well with zig. Some thoughts:

//2*x*y + 3*z + 5y - 8*x
var p = sub(add(add(mul(2, mul(x,y)), mul(3,z)), mul(5,y)), mul(8,x))

Could be rewritten to be more readable using type-namespaced fns:

var p = x.mul(y);
p = p.mul(2);
p = p.add( z.mul(3) );
p = p.add( y.mul(5) );
p = p.sub( x.mul(8) ); 

which stays pretty clean with current error handling:

var p = try x.mul(y);
p = try p.mul(2);
p =try p.add( try z.mul(3) );
p = try p.add( y.mul(5) catch y );
p = try p.sub( x.mul(8) catch x ); 

That said, I can see reasons why DSLs in general are desirable. Something like this might be possible to write:

fn ctBigMath(comptime str: []const u8, args: ...) BigInt {
    //...
}

//...

var p = ctBigMath( "2*[0]*y + 3*[2] + 5[1] - 8*[0]" , x, y, z);

where ctBigMath, at compile time, performs the parsing of the DSL and produces code equivalent to any of the above examples.

And if so, then providing functions in the std library to make it easy to write comptime DSL parsers could be a solution to a lot of things like this without adding anything to the language itself.

kyle-github commented 6 years ago

@logzero, @tgschultz I have been playing with some ideas around extending the use of comptime to include an AST rather than just functions and arguments:

fn comptime MathDSL(ast: AST) AST { ... }
var p = MathDSL(2*x*y + 3*z + 5y - 8*x) ;

I had not proposed an enhancement since I do not have this figured out. The idea would be that, somehow (this is the TBD part!), you'd get an AST and be able to descend on it, modify it and return a valid AST. This would allow almost any rewriting. If you could get the types of x and y then the code could figure out what operations to do to transform the equation into something approaching the try version above.

Note that this takes an AST, not a string, and could perform full type checking on the elements.

It is unclear how to tell the compiler to pass in the AST rather than the argument expression result.

logzero commented 6 years ago

My personal feeling is that operator overloading is too far into the "hidden behavior" territory to mesh well with zig.

I guess I am used to seeing operators as nothing more than syntactic sugar for function calls. If it is not a zig thing, so be it. :)

nicolasessisbreton commented 6 years ago

I know we are all busy, but the rational behind rejection may help future projects.

Zig is intended to replace C, but Zig borrows from C++, D and Rust (all of which have operator overloading).

Zig doc saying that operator overloading is in the 'hidden territory' is not really an explanation.

tiehuis commented 6 years ago

This wiki link articulates a bit more on some core tenets which conflict with operator overloading. These would first need to change before I think we could consider operator overloading. https://github.com/zig-lang/zig/wiki/Why-Zig-When-There-is-Already-CPP%2C-D%2C-and-Rust%3F

Summarising the key headings which are applicable:

I'll let @andrewrk chime in if there is anything extra worth mentioning.

andrewrk commented 6 years ago

Related #427

lerno commented 6 years ago

If there are Struct extensions (#1170), could we imagine twice namespaced struct functions in order to avoid overloading? So that "a * b" with a = struct Foo, b = struct Bar tries to match a function with the actual name of Foo.Bar.add(a, b).

So:

const Foo = struct {
  x: i32,
  z: float,
  extend Bar {
     pub fn add(parent: *const Foo, self: *const Bar) {
        ...
     }
}

// Foo.Bar.add(foo, bar) <=> foo + bar

Not the world's most elegant syntax and not really ready to go as is (what about foo * 2 for example, if Foo.float.add( ... ) is defined?), and not something you'd want extensively used but maybe there is a way to make it without adding overloading to everything (after all, this is like a poor man's function overloading)

Just something to get the discussion started.

ghost commented 4 years ago

Scope limited operator overloading:

One approach I haven't seen being explored yet is "scope limited" operator overloading. I imagine it could look like this:

pub fn main() void {
  const MathStruct = struct{ // 
    fn add(...) ..
    fn sub(...) ...
    fn mul(...) ...
    fn mul_2(...) ...
    // etc
    // following naming convention to map to operators.
  };

  const config = OperatorOverloadConfig.initArithmethic(); // Default init of builtin struct definition

  // define x,y,z ...  

  // notice the `try`, in case any overloading functions throw an error
  try opoverload(MathStruct, config) {
    const a1 = z*(x + y);
    // within the current scope, the above is equivalent to: 
    const a1 = MathStruct.mul(MathStruct.add(x,y),z);
  }
}

The problems with operator overloading all arise from the fact that functions are called behind the scenes (creating uncertainty around performance, allocations, errors, infinite loops, ..).

With scope limitation like above, at least the reader would get a clear warning to what might be going on while still getting to enjoy the readability and ergonomic benefits of operator overloading.

Another benefit here, is that all the overload definitions would be located in a single namespace (MathStruct in the above example), where of course this namespace could delegate the actual calculations, but the entry point would be non-ambiguous.

The main remaining issue here is allocation. I don't know how to approach that yet, but perhaps an allocator could be accessed within the opoverload block as part of the config passed to the block.

The config could be used to select which groups of operators would be overloaded [(+ - / *) vs (| & ~) vs (== >= <=) etc ], whether to fold from left or right in expressions like (a + b + c), and so on.

I also believe the AST could contain information on which function is substituted for each operator, so that IDEs could provide "hovering" information within the code editor, and "go to definition".

BarabasGitHub commented 4 years ago

I'm not a big fan of operator overloading but I could see it work if it is only defined for structs/arrays which only have one arithmetic type. Such as these

const Float3 = struct {
    x: f32,
    y: f32,
    z: f32,  
};

const Integer2 = struct {
    x: i32,
    y: i32,
};

const ArrayType = [4]u32;

Note, I didn't include slices as their length is not compile time known and require loops and more elaborate 'magic'.

Then the operators would just work element-wise, so for the Integer2 case this would mean:

const a = Integer2{.x=1, .y=2} + Integer2{.x=3, .y=5};
std.testing.expectEqual(Integer2{.x=4, .y=7}, a);

const b = Integer2{.x=1, .y=2} * Integer2{.x=3, .y=5};
std.testing.expectEqual(Integer2{.x=3, .y=10}, b);

Comparison operators don't work with this obviously, as they result in a different type and it's non-trivial to do anything with that.

This would be able to automagically translate to SIMD instructions as anything with 4 floats would could be done with SSE for example and AVX for 8, etc. And clang usually manages to do this on it's own. Though I don't know what it'll do for non power of two sizes.

The main advantage is that there are still no function calls or user defined functionality with these overloaded operators.

One issue I can see with this is that it'll also be 'enabled' for structs for which arithmetic operators may not make much sense. Such as config structs which just happen to contain only u32 types. This could be solved by indicating in the definition of the struct that you want these arithmetic operators.

ghost commented 4 years ago

How about something like this?

0) An infixop must have two parameters and a return value. 1) They are enforced to be of type @This(). 2) Only the parameters are in scope. No userspace definitions from outside the block are accessible. If @This() uses a non-core definition internally, it is a compile error. 3) All @core definitions are accessible, except @import, and @cImport. 4) No mutation of parameters is allowed. 5) <opname>= is automatically defined. 6) <opname> must be one of +, -, *, /, %, +%, -%, *%.

const Self = @This();

pub const @'*' = infixop (self, other) Self {
    // do whatever via self and other, return something.
}

// 'a * b' and 'b *= a' are now usable.

0) A prefixop must have one parameter and a return value. 1) They are enforced to be of type @This(). 2) Only the parameter is in scope. No userspace definitions from outside the block are accessible. If @This() uses a non-core definition internally, it is a compile error. 3) All @core definitions are accessible, except @import, and @cImport. 4) No mutation of the parameter is allowed. 5) <opname> must be -.

const Self = @This();

pub const @'-' = prefixop (self) Self {
    // do whatever via self, return something.
}

// 'b = -a' is now usable.

AFAIKT, this has the following desirable properties:

If I understand correctly, this would even protect from stupidity like having + read from a file, as the type information would not be imported inside the infixop/prefixop's scope.

The most I can imagine this being abused is within embedded systems by writing directly to IO memory, but that moves operator overloading from "being a footgun" to "being a hobby".

ghost commented 4 years ago

@floopfloopfloopfloopfloop

Only the parameters are in scope. No external definitions are accessible.

Can you specify a bit more? mycustomconstant is not available, but @sqrt would be?

Two additions:

1: It might be possible to add the following to your list of desirable properties:

2: I think your idea goes well with the scope restricted operator overloading

opoverload{ // yes. in this scope, operators WILL call user defined "functions". deal with it
  _ = a + b; // call infixop function
  _ = 4 + 2; // error. only overloaded operators in this scope (or maybe too restrictive?)
}
_ = 4 + 2; // back to "normal"

Not saying that these two additions are aligned with what you had in mind, but these additions together with the points you made would make operator overloading so transparent and controlled that IMO the drawbacks normally associated with operator overloading would not be valid anymore.

It would be interesting to see a more real world example though, like complex numbers or matrices.

I also think your suggestion on operator overloading would go well with typedef (#5132), because these infixop/prefixop functions could be put in typedef namespaces, making it possible to define custom operators for arrays for example. Typedef operator overloading would HAVE to be scope restricted though, as you could typedef a primitive type that supports the overloaded operator to begin with.

ghost commented 4 years ago

@user00e00

By "no external definitions are available", I mean no userspace-defined expressions are accessible, i.e. anything assigned to a const or var, imported or otherwise. As @import and function definitions are/(will be) expressions, this should sufficiently sandbox the operators to non-importing @core definitions within the infixop / prefixop blocks. Combined with readonly parameters, we've got enough to do math with, and I think nothing else. I've edited the post to clarify.

I don't know if it's possible to have comptime known execution 'time'/ticks. With the above restrictions, I don't see why it would help.

(..unless you're trying to comptime-detect infinite loops. That'd be useful generally. :thinking: )

jdknox commented 3 years ago

The docs say "Zig's SIMD abilities are just beginning to be fleshed out." But can this proposal be done using SIMD Vectors as a workaround? At least for common 2-, 3-, and 4-wide 32-bit integers/floats?

williamhCode commented 10 months ago

Why not just make operator overload behave the exact same as functions? This way we would solve all issues related to error handling.

If add may return an error, we can already handle it like this:

const a = try add(try add(x, y), z);

So why not just make this valid:

const a = try (try x + y) + z;

This is more or less how Rust does operator overloading works with its error handling system, except it uses (x + y)? instead of (try x + y), and (x + y).unwrap() instead of (x + y catch unreachable).

Edit: I just want to add a note that even though I understand Zig highly values "No hidden control flow", I believe wholeheartedly towards adding operator overloading. There are a lot of problems Zig is trying to solve, but I don't think that the downsides to operator overloading really justifies the need to not include it in the language. If you make a language too complex like c++, it's up to the burden of users to figure out how to write good code, but if you limit users too much, it becomes a pain where it's hard for them to express things in a less complicated and verbose manner. The recurring argument against operator loading is that, an operator could hide expensive operations. However, if operator overloading is just syntactic sugar for functions, they could simply be treated like functions. The argument is like saying that we should ban function names less than a word, because it's not descriptive enough and you can't tell what the function is doing just from its name. Sometimes you have to trust the users that they know what they're doing; Zig already does that by putting the burden of memory management onto users. These are just my opinions on this issue, so take them as you will.

burnpanck commented 6 months ago

I absolutely second @williamhCode sentiment. I believe that "no hidden control flow" is then a good thing, if ensure that the code reads the way it executes; it is immediately obvious under which conditions statements may get skipped. I don't see how that would preclude operator overloading. Seeing a + in code makes immediately obvious that an "addition" is happening. That "addition" is never being skipped through code-flow within the operator implementation, no matter if that implementation comes from the user, the runtime or the instruction set. Operator overloading does not break encapsulation.

If code-flow hidden in the implementation of an operation where a reason to concern, why do we allow the runtime to supply such "hidden" control flow? Depending on the processor, some operations may be instructions or they may be library code supplied by the runtime. Or is it that we make special exceptions for runtime and compiler, but ban the user from such code-flow? Why do we allow users to write functions then?

The reason why we allow functions is because abstraction is a good thing. It allows to encapsulate complex operations behind a relatively simple name, and simple pre- and post-conditions. This way, the function user can focus on what's relevant: the "what" of the function call, not the "how". The same is true for operator overloading. If it helps encapsulate details that are not relevant to the caller of the operation, then it's a good thing. I don't see why that should be restricted to an arbitrary limited set of operations.

I believe a language should make it easy to write good/correct code while difficult to write "bad" code. There are few areas where operations are as well-defined as those where we use the language of mathematics to specify properties. That language uses operators. IMHO, it would be stupid not to allow to use that language to describe those properties in code. For example, "physical quantities" is a distinct concept of a number (and one that is very dear to me). Many languages are slowly developing tools to map that concept into their space, often via libraries - because users of such libraries know how difficult it is to write correct code that needs to track physical quantities as numbers with a hidden, implicit physical unit. Mapping that concept into the language frees you from having that hidden, implicit unit by making it explicit, and tracking it through the mathematical operations on them. It enormously simplifies writing correct code.

Sadly, Zig does ban such a tool outright, on the grounds of an argument that I consider inconsistent in it self, as I outlined above.

lerno commented 6 months ago

@burnpanck I think the main reason people dislike operator overloading is simply because of such practices as in C++, where they are not used for actually implementing for example arithmetics on a custom types, but to implement various kinds of implicit and/or unexpected behaviour. The canonical example being C++ using << for printing / concatenation. Even enforcing some kind of reasonable behaviour on overloads, so that they are only used for the expected things, is not trivial. For example, consider C where two pointers cannot be added, but they can be subtracted! Which means that to fully express such things one needs to be fairly lenient, but such lenience can then be misused. So it's hard.

burnpanck commented 6 months ago

Even enforcing some kind of reasonable behaviour on overloads, so that they are only used for the expected things, is not trivial. [...] Which means that to fully express such things one needs to be fairly lenient, but such lenience can then be misused. So it's hard.

So the stance of Zig is "if we cannot prevent most/all bad use of a feature, it should be banned outright"? Shouldn't we ban "writing code" then?

Which overloads are reasonable and which aren't is indeed a domain-specific question, so if the language tries to enforce that, it basically provides a white-list of "acceptable" domains which will always be very limiting. Why not give the user the tools to define what operations are reasonable? Sure, the feature can be misused, but you cannot prevent users from writing illogical code anyway.

Has anyone ever made a list of actual examples of cases where operator overloading has been "misused" and it has become mainstream enough that one has to use those operators? There is of course C++'s <<, but that is basically the only example I have encountered in my 30 years of programming experience. In C++23, this actually got rectified, we finally have std::print. In python, where the language brings no constraints whatsoever, I have never heard anyone complain about any operator's behaviour. So despite the potential for misuse, it seems one can in fact trust the user

lerno commented 6 months ago

@burnpanck, no no, I don't speak for Zig at all. I simply offer a possible explanation of how "no hidden control flow / allocations / simplicity" might tie into a rejection of operator overloading.

BreakingLead commented 6 months ago

Why we can't add operators like $+ $* $- and enable overloading them in order not to be confused with + * - or something?

igor84 commented 1 month ago

You should take a look at what Odin lang did. It also doesn't allow operator overloading, but common operations on vectors and matrix types are built in as are those types. It's philosophy is that since it already provides things that 99% of people use operator overloading for there is no need for operator overloading. I am not saying that exact thing would be a good idea for Zig but it is an idea worth having on ones mind.

travisstaloch commented 1 month ago

You should take a look at what Odin lang did. It also doesn't allow operator overloading, but common operations on vectors and matrix types are built in as are those types.

@igor84 zig already supports math ops for @Vector types if thats what you're referring to:

https://ziglang.org/documentation/0.13.0/#Vectors

Vectors support the same builtin operators as their underlying base types. These operations are performed element-wise, and return a vector of the same length as the input vectors. This includes:

Arithmetic (+, -, /, *, @divFloor, @sqrt, @ceil, @log, etc.)
Bitwise operators (>>, <<, &, |, ~, etc.)
Comparison operators (<, >, ==, etc.)