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

Adding a rough draft of the "minimum viable product" for the .NET Libraries APIs to support generic math #205

Merged
merged 2 commits into from
Nov 22, 2021

Conversation

tannergooding
Copy link
Member

@tannergooding tannergooding commented Apr 15, 2021

IMPORTANT: This is very much a draft of the API surface and there are several open questions and design points still to be resolved.

The current UML diagram looks something like:

UML

There are four related language proposals for this feature:

There are also runtime related changes for this feature:

@tannergooding
Copy link
Member Author

@tannergooding
Copy link
Member Author

This does not currently capture notes around # of interfaces impacting startup, around requirements for DIM support to enable versioning, any real overview of how concepts like Commutative or Associative will be supported, or a few other details that have been covered in meetings.

accepted/2021/statics-in-interfaces/README.md Outdated Show resolved Hide resolved
accepted/2021/statics-in-interfaces/README.md Outdated Show resolved Hide resolved
accepted/2021/statics-in-interfaces/README.md Outdated Show resolved Hide resolved
accepted/2021/statics-in-interfaces/README.md Outdated Show resolved Hide resolved
accepted/2021/statics-in-interfaces/README.md Outdated Show resolved Hide resolved
accepted/2021/statics-in-interfaces/README.md Show resolved Hide resolved
Comment on lines +780 to +1459
* System.Tuple
* System.ValueTuple
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ValueTuple is instantiated a lot. Adding more interfaces to it is very problematic performance-wise.

public interface INegatable<TSelf, TResult>
where TSelf : INegatable<TSelf, TResult>
{
// Should unary plus be on its own type?
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If not, this seems like a good case for supporting default implementations since (I think it's safe to say) 99% of implementations of unary + will just return value unmodified.

accepted/2021/statics-in-interfaces/README.md Outdated Show resolved Hide resolved
accepted/2021/statics-in-interfaces/README.md Outdated Show resolved Hide resolved
where T : struct, IAddable<T>
{
ThrowHelper.ThrowForUnsupportedVectorBaseType<T>();
return left + right;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In your opening sentence you wrote "an important numeric helper type for SIMD acceleration". How does this implementation play in with SIMD?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SIMD is basically just doing a given scalar operation n times, where n is the number of elements in a hardware vector.

To provide a software fallback we have to support scalar operations over arbitrary T and so today that involves doing if (typeof(T) == typeof(...)) checks everywhere.
Generic math is one way that can be simplified, as many helpers could be simplified down to just where T : INumber<T>

accepted/2021/statics-in-interfaces/README.md Outdated Show resolved Hide resolved
accepted/2021/statics-in-interfaces/README.md Outdated Show resolved Hide resolved
// This, in a way, obsoletes Math/MathF and brings float/double inline with how non-primitive types support similar functionality
// API review will need to determine if we'll want to continue adding APIs to Math/MathF in the future

static abstract TSelf Acos(TSelf x);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are many of these static rather than instance? i.e. I'm not sure why this isn't just TSelf Acos();?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because these kinds of operations have traditionally been static in .NET.

Also, there is a perf consideration here because this is byref (or inref for readonly struct) which can hurt inlining and other optimizations done by the JIT.

{
public struct Byte
: IBinaryInteger<byte>,
IConvertible,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth denoting somehow that some of these interfaces are already implemented by these types.


There are several types which may benefit from some interface support. These include, but aren't limited to:
* System.Array
* System.DateOnly
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You already included this one above. Same with TimeOnly.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forgot to remove them from this list when I added them above. Will fix.

* System.TimeOnly
* System.Tuple
* System.ValueTuple
* System.Numerics.BigInteger
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd have expected this one to be a given.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Several of them are "given", but I haven't done any scenarios for them yet and so am just tracking them below until I can finish writing the code locally.

* System.Numerics.BigInteger
* System.Numerics.Complex
* System.Numerics.Matrix3x2
* System.Numerics.Matrix4x4
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are lots of comments throughout the interfaces about how various decisions are being made in order to support types like matrices, so I'd hope we either implement the interfaces on matrices or don't constrain ourselves by things we aren't ourselves willing to do :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This list is more a todo than a "maybe we should think about these".

Even if we decided to not support our own types, for whatever reason, ensuring the interfaces work for 3rd party types is an important scenario.
(I do plan on defining interfaces and including them in the design doc here, but since I only got compiler bits a couple days ago, it was relatively slow going to "validate" the designs 😄)

@bartonjs
Copy link
Member

bartonjs commented May 3, 2021

Video

  • Should we eliminate IParseable and just add ISpanParseable?
  • Rename all lhs parameters to left and all rhs parameters to right
  • We need a consistent name for the (low-level) operator defining interfaces.
    • I[Purpose]Operators seems the most reasonable, with Purpose being the primary metadata name, or concept
    • IEquatableOperators => IEqualityOperators
    • IComparableOperators => IComparisonOperators
    • IAddable => IAdditionOperators
    • ISubtractable => ISubtractionOperators
    • IRemainder => IModulusOperators
    • INegatable => IUnaryNegationOperators
    • IShiftable => IShiftOperators
  • We need a consistent name for the value-exposing interfaces (MinValue/MaxValue)
    • (Maybe they're the best they're going to be?)
    • IAdditiveIdentity =>
    • IMultiplicativeIdentity =>
    • IMinMaxValue =>
  • INumber we discussed, and feel it's probably the right name
  • INumber.Create* feels a little weird. Is it the right level? Is there a good way to help convey what TOthers do and don't work?
    • Should they be a different interface, higher or lower level?
  • BinaryInteger.Rotate* second parameters should be TSelf, not int.
  • IBinaryNumber and beyond were only skimmed
  • IBinaryNumber's methods probably want some changes (@bartonjs has some regrets about "isUnsigned", at least).

@SingleAccretion
Copy link

SingleAccretion commented May 3, 2021

Putting a note here on IBitwiseOperators - they can be viewed as operators on sets. So, | would be union, & - intersection, ~ - complement, ^ - XOR. When viewed from this perspective, the name becomes unfortunate. An alternative of ILogicalOperators was suggested, but did not gain traction due it not being that good of a name when the primary use-case of bitwise arithmetic is considered.

Maybe it is better to leave sets out of scope for these interfaces.

@vladd
Copy link

vladd commented Nov 21, 2021

@tannergooding

  • INumber is distinctly around "scalar" values, that is a value that can be represented as a single concept. Things like Complex or Vector2/3/4 have two or more parts to them and are distinctly not "scalar"

Sorry for asking so late in the design time, but what is an advantage in not treating a Complex as an INumber?

From the user's point of view, a Complex is just a number, which one can e. g. add, multiply, accumulate, test if two of them are close enough, check if a sequence converges and so on. Having Complex not support INumber means that all the generic algorithms on numbers would need to be reimplemented on Complex with direct copy and paste. So this is a clear disadvantage. What are we gaining by not having Complex included?

From the user's point of view, having a pair of values inside is in most cases just an implementation detail, the same as having several backing fields in decimal. The only actual difference is that Complex is not ordered, so there's no useful way to conform to IComparisonOperators<,> (which means that INumber ought to not include ordering). But having all other operations defined on Complex would be an advantage. The same way having more exotic numbers to implement INumber (e. g. quaternions) would be an advantage and allow using generic algorithms for them as well.

By the way, Vector2 and so on are actually different beasts: one cannot multiply and divide them. They don't feel like a (single) number to the user.

@vladd
Copy link

vladd commented Nov 21, 2021

(I'd argue that increment doesn't make much sense for non-integers. For int and other discrete ordered types, increment is switching to the next number, but for floating point increasing a number by a multiplicative identity has no use. And why no interface for decrement operators?)

@charlesroddie
Copy link

charlesroddie commented Nov 21, 2021

Complex - "scalar" needs a definition

the computer hardware concept [of scalar]

Nothing in the spec, name, blog articles on INumber indicate this is a hardware supporting-feature restricted to SIMD or similar.

a value that can be represented as a single concept

A Complex number is a single concept both in mathematics and in programming (single type), and your assumption that having two fields prevents this is unjustified.

the current design doc has a concept around what a scalar vs a vector is

The only concept around scalar in the design doc is the use of TScalar which needs to indirectly satisfy some interfaces, none of which exclude Complex.

Take inspiration from Swift

It tries to take into account what other programming languages

The doc specifically mentions Swift, but Swift has a much more logical approach here, which in particular pays considerable respect to mathematics, and which in particular doesn't have the first three decisions that you reference (requirement for parsing/formatting operations, type-unsafe conversion, complex numbers aren't scalars/numbers):

  • The relevant swift protocols - AlgebraicField, SignedNumeric - do not require string parsing and formatting: instead, types may implement Codable.
  • I don't see anything about conversion to and from each other in these protocols.
  • Swift's complex number "[conforms] to the obvious usual protocols [including] AlgebraicField (hence also AdditiveArithmetic and SignedNumeric)".

(I am not a Swift programmer so please correct me if I am wrong here.)

From the Swift specs, its numeric types have been much better designed copying over the Swift spec would be a drastic improvement. It has a good combination of mathematical conformance, type safety, and simplicity. The conformance of the interface hierarchy to mathematical concepts is icing on the cake. The dotnet process is currently heading for something much worse because criticism from the community is not engaged with seriously.

The differences are going to be:

  • Swift allows for the complex numbers, arbitrary precision numbers, rational numbers - anything that meets the simple interface. Dotnet somehow has a question mark over types that don't conform to a very personal definition of "scalar", althought there is nothing in the interface that specifies that. Making INumber work for historical styles of writing dotnet CRUD apps seems to have high priority, and making it work for mathematical uses is out of scope.
  • Swift does not have the two INumber flaws of parsers/formatters and unsafe conversion that make it very difficult or impossible to implement safely in user-defined types.

the .NET type system

What in the dotnet type system prevents implementing INumber correctly? In particular there is no requirement for parsers on any type, no requirement for conversion between two different types, and no requirement that a type has only one field.

@jl0pd
Copy link

jl0pd commented Nov 21, 2021

INumber should be simple interface that exposes only actual number-related operations, not parsing, formatting and conversions. Otherwise we will get second System.Collections.ICollection and System.Collections.Generic.ICollection<T> that have more meaningless members than useful ones. These have notion of synchronization and readonlyness, while actually it should be in different interfaces. Such mistakes should not happen in new code.

Methods can require multiple constraints, which eliminates need in one complex interface:

void PrintSum<T>(IEnumerable<T> items) where T : INumber<T>, IFormattable
{
    T sum = items.Sum();
    Console.WriteLine(sum.ToString(CultureInfo.InvariantCulture));
}

If all operations are actually needed under one interface, then create rich interface: IBuiltinNumber or IFormattableAndParsableAndConvertibleAndInrecementableAndDecrementableAndSoOnNumber.

Keep simple things simple

@olivier-spinelli
Copy link

+1 for the Swift model.

Seems that this discussion is not appealling to a great numbers of dev, but if it were the case, I believe that most of them would like the cleanest possible model that handles beasts like Complex or other multidimensional "numbers" rather than a simplistic one.

This is a low-level aspect that aims to be used by senior/framework/kernel/core developpers, not on an applicative layer that must be "easy for juniors to jump in".

@tannergooding
Copy link
Member Author

@vladd

I've tried to respond to your points. This became a bit of a long response so I've put it in a collapsible region here and followed up with a second post covering more direct examples that address everyone more generally.

From the user's point of view, a Complex is just a number, which one can e. g. add, multiply, accumulate, test if two of them are close enough, check if a sequence converges and so on.

I'd agree that this is the case from a higher level overview of a "complex' number, but not of the System.Numerics.Complex type.

Having Complex not support INumber means that all the generic algorithms on numbers would need to be reimplemented on Complex with direct copy and paste. So this is a clear disadvantage. What are we gaining by not having Complex included?

This is actually a good reason on why it is separate. Complex, having two parts, introduces additional concepts that mean outside of specialized considerations (e.g. simple scenarios like Sum or Clamp), it isn't appropriate to treat it like other numbers. When you do have just the simple scenario, there are other interfaces that are more appropriate.

Also consider that these interfaces need to be semi-extensible for the future. We do not want to be introducing INumber2, INumber3, etc and so we need the ability to expose new APIs and implement them via Default Interface Methods without risking introducing arbitrary breaks on user APIs.

Let's drill down into some specific examples of APIs like: Abs or Sign. These APIs both exist and logically have a result for Complex, however the considerations around implementing it are suddenly different.

For scalar numbers, Sign is defined simply as:

if (x < 0) return -1;
if (x == 0) return 0;
return +1;

Likewise, for Abs, the algorithm is effectively:

return (Sign(x) == -1) ? -x : x;

For Complex, none of this holds true anymore. Unlike say UInt128 where even with two fields, its 1 set of contiguous interpreted bits and so therefore represents 1 logical value. Complex is two distinct parts and represents two logical values combined to represent something new. When dealing with many algorithms, both sides have to be considered to produce something accurate. If you want the absolute value and Complex.Imaginary is zero, then its indeed the same as any other number. But as soon as Complex.Imaginary becomes non-zero, everything changes and algorithms are no longer computable simply based on the input of 1 logical value. You start having to consider things like whether Real > Imaginary and then do a complex computation involving multiplication and square roots.

Likewise, operations like Abs don't return Complex; the standard behavior is to return the underlying floating-point type.

So once you drill down into things, if you want INumber to also represent things like Complex then you have to start removing functionality, this immediately makes the interface less useful for otherwise "core" scenarios. Like was called out, this also means numbers are no longer comparable by default, which means that various other operations including things like Clamp, Min, and Max go away. You'd continue doing so until you're basically left with IAdditionOperators, ISubtractionOperators, IMultiplicationOperators and IDivisionOperators. You may or may not be able to expose a concept of Additive or Multiplicative identity, concepts like Zero or One. You cannot expose a way to create a given T; where T : INumber<T>, etc.

And if you're thinking removing those other members is too much or taking the idea too far, there has been feedback asking for exactly that as well. These interfaces will not fit the wants of everyone, they will end up defining a common contract that is believed to be beneficial to the majority of devs, within the limitations we have for designing and exposing such a system.


By the way, Vector2 and so on are actually different beasts: one cannot multiply and divide them. They don't feel like a (single) number to the user.

The standard practice is that Vector2 can perform element-wise multiplication and division and so Vector2 * Vector2 is typically "well-defined" and exposed. Likewise, they typically also expose IVector<T> * T. Many types support the concept of multiplication or division, and many of them are not "number" types in the strictest sense.


I'd argue that increment doesn't make much sense for non-integers. For int and other discrete ordered types, increment is switching to the next number, but for floating point increasing a number by a multiplicative identity has no use. And why no interface for decrement operators?

IDecrementOperators is defined in the .NET 6 preview and in the current draft in this PR. Additionally, for better or worse, increment/decrement are defined as "standard" operations on floating-point types and it represents the concept of "increase/decrease by the integer one"

@tannergooding
Copy link
Member Author

@charlesroddie

I've tried to respond to your points. This became a bit of a long response so I've put it in a collapsible region here and followed up with a second post covering more direct examples that address everyone more generally.

Nothing in the spec, name, blog articles on INumber indicate this is a hardware supporting-feature restricted to SIMD or similar.

You're taking the term computer hardware too literally here. I meant in the general sense of computer science/programming and not mathematics. Programming borrows many concepts from mathematics and then "simplifies" or otherwise twists it around a bit to fit the needs and appropriate use cases.

Computer Math != Real Math, even though various terms and concepts are similar. You have a finite approximated system where things like rounding or overflow have to be considered. Because of this, concepts like rings, groups or fields do not translate well into most programming languages.


A Complex number is a single concept both in mathematics and in programming (single type), and your assumption that having two fields prevents this is unjustified.

Yes, a single type. But one that is logically two parts. This is also not two parts in the sense of two fields. UInt128 likewise has at least 2 fields, but is exposing a single logical value made up of 128-bits.

Complex on the other hand is at least two fields where the underlying value is represented by two distinct parts: Real (a) and Imaginary (b), which form the expression a * bi.

For UInt128 its possible that some future hardware allows this to become a single field. For Complex, that isn't the case and its always two distinct and separate parts.


The doc specifically mentions Swift, but Swift has a much more logical approach here, which in particular pays considerable respect to mathematics, and which in particular doesn't have the first three decisions that you reference (requirement for parsing/formatting operations, type-unsafe conversion, complex numbers aren't scalars/numbers):

Swift does indeed expose several things differently here. Part of this because it was designed with things like associated types as first class and implemented this a lot earlier.

SignedNumeric exposes operator -(T, T), which is strictly defined as returning the additive inverse. It inherits from Numeric which defines operator *(T, T), a Magnitude (with an associated type), and an init function which constructs a value from a given T; where T : BinaryInteger.

Numeric itself also implements ExpressibleByIntegerLiteral which has an associated type and another init method; and AdditiveArithmetic which exposes operator +(T), operator +(T, T), operator -(T, T), and Zero.

ExpressibleByIntegerLiteral implements nothing.

AdditiveArithmetic implements Equatable which defines operator ==(T, T) and operator !=(T, T).

Equatable implements nothing.

The init method in particular gets interesting because it is effectively the TryCreate in this proposal. The main difference is its restricted to BinaryInteger, rather than any Number.


Swift's complex number "[conforms] to the obvious usual protocols [including] AlgebraicField (hence also AdditiveArithmetic and SignedNumeric)".

Indeed. However, the design notes also calls out various considerations that had to be made.

You cannot do Complex<T> op T and so you must construct a Complex<T> from the underlying real part before doing any operation. This itself isn't necessarilly problematic and fits in with the overarching goals of INumber<T> in .NET, but it does mean that Complex becomes more costly to use in many scenarios.

It basically throws out the concepts of Signed Infinity, NaN, and Negative Zero. This immediately hinders compatibility with several other libraries and its general usefulness when IEEE 754 compliance is expected or appropriate.

It discusses some of the tradeoffs in implementing the Numeric protocol, such as needing to specialize on what Magnitude means.

Due to various reasons, including back-compat, Complex nor Real could be in the standard library, they had to be an external set of protocols.


From the Swift specs, its numeric types have been much better designed copying over the Swift spec would be a drastic improvement. It has a good combination of mathematical conformance, type safety, and simplicity. The conformance of the interface hierarchy to mathematical concepts is icing on the cake. The dotnet process is currently heading for something much worse because criticism from the community is not engaged with seriously.

I am very much engaging in this seriously and considering the input that's been given.

At the same time, I'm trying to explain as the area owner why I believe that input isn't as impactful as the relatively few who are stating it believe it to be.

Swift has a good design here, but it also hit limitations and other issues that .NET can avoid by dealing with up front. Likewise, there are certain aspects that won't translate over well, even where they would be nice, because .NET doesn't have a corresponding concept (such as associated types). Adding such features has been considered but in the most likely scenario, wouldn't make it for this feature or worse would delay this feature for a couple more years at least.


Making INumber work for historical styles of writing dotnet CRUD apps seems to have high priority, and making it work for mathematical uses is out of scope.

Making these interfaces useful for the majority of developers in .NET is indeed a use case and consideration here.

It's nice to be able to define libraries that follow mathematical concepts and have generic algorithms. It simplifies your code, makes it easy to use, etc. However, once those libraries get into the hands of downstream users; they need to actually be able to use the types. It would be nice if the functionality they needed was likewise available by default.

To that end, most types need or are expected to support things like Hashing, Formatting, and Equality. For better or wose, these concepts are built into System.Object and so every type in .NET supports them to some extent.

Such functionality allows you to do "basic" operations with types in .NET, including storing it in a collection or dictionary and displaying some readable value to the user (Debugger, Console, GUI, etc).

Some types extend this functionality a bit further by supporting additional formatting modes (IFormattable), by providing strong equality (IEquatable<T>), and will also be able to directly support parsing (IParseable).

These interfaces will allow users to extend and expose additional contracts on top that expose other interesting functionality.


What in the dotnet type system prevents implementing INumber correctly. In particular there is no requirement for parsers on any type, no requirement for conversion between two different types, and no requirement that a type has only one field.

There are real world costs to the number of interfaces exposed on a type and it can have measurable impact on startup and usage of the type, such as when doing type checks or casts.

Particularly when these interfaces are on the primitive types, which every app will use, we have to be careful about how many we're exposing.

Likewise, .NET doesn't support concepts like associated types (and likely won't be getting them for this feature) and so certain concepts from languages like Swift don't translate over well.

While there are indeed no requirements that a type implement these features, they are semi-basic functionality when dealing with numbers or types in most real world contexts, which I'll get to in a follow up post here.

@tannergooding
Copy link
Member Author

tannergooding commented Nov 21, 2021

To elaborate a bit more on the above and give specific examples, let's consider that there are roughly two groups that these interfaces have to appeal to.

First, these interfaces need to appeal to library and framework authors who are defining their own types and want other libraries or applications to be able to use them. This group likely wants a number of fine grained interfaces that it can fit exactly to its needs and not have to extend the scope of their support to additional concepts.

Second, these interfaces need to appeal to application and service authors who are trying to provide a functioning app to their userbase. This group is somewhat in the opposite camp and likely wants a number of large grained interfaces so they get the most functionality without having to specify n different constraints at every usage point.

My job is to ensure that both groups get their needs met, that the interfaces are extensible for the future, and that various limitations of the .NET type system are considered. This includes considerations that each interface exposed, particularly on the primitive types like Int32 will incur startup overhead to every application/service. This includes considerations around back-compat and things like modifying generic constraints being a breaking change. This includes considering possible future extensions to the runtime or language including things like associated types, shapes/roles/traits, or simply new APIs that need to be exposed (Math.Clamp didn't. come about until .NET Core 3.1).


Now, lets go over some of the most simplistic apps that every beginner needs to learn and therefore what might be expected for beginners to be able to use these types.

Most beginners start out on the Console with simple Hello, World! programs. One of the first things you learn is how to display strings out to the user. After that, you often learn how to take user input and display it back, often in the form of Hello, Tanner!. This then naturally extends to taking in other inputs, such as an age, handling small amounts of error cases, etc. When you eventually move into UI based applications (including websites), you often get very similar scenarios where you deal with user input, perform some action based on it, and return results back.

To this extent, one of the first things you learn to do in programming is:

  1. how to take data used in your code and convert it into something you can display back to the user
  2. how to take user input, which is most often in the form of a string, and convert it into data useable in your code

Thus, one of the simplest and most basic operations is serialization and deserialization of data. This extends to almost every real usage of a type in any application. It is prevalently shown in the usage of things like JSON, XML, YAML, in a majority of beginner tutorials for languages and application frameworks, and in how users interact with a computer, communicate with eachother, and input information via text on a keyboard and recieving text as a display on the other end.

It is my belief that this is a core pillar to what is exposed on these interfaces and will be effectively a baseline expectation for many application authors to effectively use these types. That it will likewise play a core part in ensuring that beginners can easily learn about such things in a natural way and integrate it into their toolset. That without providing formatting you are cut-off from the most critical thing a type can provide which is something that the consumer of the type can read and interpret. Likewise, it provides parsing by default as the logical counterpart to formatting because creating a number from user input is a core scenario.

I don't believe there is any case where formatting a number type is inappropriate, at the very least it applies to providing a good debugging experience. I expect there are probably some cases where parsing isn't required or appropriate or unnecessary, but I also expect those to be a minority in the scheme of general applications and usages. It is therefore worth exposing as part of the core contract.


Expanding our horizons a bit, lets consider the Sum method as its basically the simplest generic algorithm. We simply take in a set of T and return some T: T Sum<T>(T[] values)

In the strictest sense, this algorithm doesn't need to do anything with external user input and so parsing is off the table. Formatting still extists for the purposes of the debugger. Likewise, this algorithm, mathematically speaking is relatively straightforward, just add things together until you nothing is left.

Now, lets consider some scenarios that real world programs have to consider and where the basic math part breaks down. Starting with, unlike "real math", your T likely has a bounds and that any operation can overflow. While it could be BigInteger and therefore only be bound by the limitations of memory on your computer, in all actuality it's mostly likely int and therefore can represent approx. +/-2.14 billion.

When implementing such an algorithm you then have effectively three choices:

  1. Allow overflow and just return the lowest modular arithmetic based result
  2. Disallow overflow and fail
  3. Return a type other than T so you can take return something that is "big enough" to fit the result

The first case is the default and while valid, most non-developers find it confusing when 2147483647 + 1 == -2147483648. The second case likewise has problems where if your inputs are say byte or simply sum as too big, then you can't get an accurate result. The last case is the most interesting but it also has issues. If you for example change the signature to BigInteger Sum<T>(T[] values) then you work with any integer type, but now your algorithm doesn't correctly support float, double, or decimal and its significantly slower and more expensive and therefore not a "drop-in replacement". Switching it to U Sum<T, U>(T[] values) fixes that but it leaves the problem of "I have a T, I need a U".

This brings us into the second "basic" operation that you need to deal with in computer programming: converting your data from one type to another (which is indeed very similar to the parsing/formatting case, perhaps there is a pattern here).

It is my belief that this is also a core pillar to what is exposed on the interfaces. Number types are simply not useful on their own because we are in a finite system with bounds and limits and therefore in order to account for this while still allowing things to be efficient, we have many types each with their own different bounds and limits. In order to succesfully work with data, there are cases where you need to convert between types so you can have different more appropriate limits for your use-case.

This is why Swift exposes the init method and why we expose the TryCreate method. However, Swift has a limitation in that its numbers can only be created from binary integers and therefore it is not possible to define a generic sum algorihtm that works for both integer types and floating-point types. That is, Sum<int, long> is possible using just the Numeric protocol; however Sum<float, double> is not because you cannot, given just some Numeric construct a double from a float. Instead, you have to go down to the FloatingPoint protocol and now have to expose at least two methods.

.NET tries to account for this by allowing conversions from T to U for any numeric type via TryCreate APIs. It's always possible for there to be some U that can't be created from some T, and so in that sense you lose some "safety" because you can no longer guarantee that Sum<T, U> is valid for any differing T and U. However, the API can decide what is appropriate to do in this case (generally failing, as it would do on overflow).

Some of the wonkiness can also be mitigated by ensuring that IBinaryInteger and IBinaryFloatingPoint expose ways to get the underlying parts of their data. This would likely be slower than direct support for a given T <-> U but it also allows types to "generally support conversion to/from another INumber"


We then get to cases like Complex, which as its name implies is a bit complex. Complex is unlike the primitive number types (int8/16/32/64, uint8/16/32/64, float16/32/64). It's unlike many user-defined types dealing with a single logical set of contiguous bits (say things representing measurements like temperature, distance, etc). It's even unlike user-defined types that can be represented by a single logical set of contiguous bits, but may for many reasons represent themselves as multiple fields (BigInteger, Rational, etc).

Complex is fundamentally a data type of two distinct parts, one real (a) and one imaginary (b) representing a + bi. It could decide to represent these parts many different ways, but fundamentally it is two distinct units combined into a single type. Various operations need to be able to consider each unique part and I do not believe it is simply enough to have some IComplexNumber that derives from INumber or IBinaryFloatingPoint to represent these parts. Even when considering things like getting the absolute value complex has a different signature in that it isn't T Abs(T value), it is instead U Abs<U>(T value) where Swift represents U as an associated type.

Now representing U as an associated type is nice and avoids some of the issues you run into with things like int.MinValue where it isn't representable as an int; however, it also introduces complexity. For starters, .NET doesn't have and likely isn't getting associated types by .NET 7 so we'd have INumber<TSelf, TMagnitude> which is not nearly as nice as just INumber<T>. Additionally, this is somewhat-problematic because it reintroduces the issue of "I have a T, I need a U", or vice-versa. That is, you were writing some algorithm that was dealing with T and you needed the absolute value so you called Abs(). With the associated type, you now have a U and need to do all subsequent operations as U or you need some way to convert that back to a T so you can continue working as T.

Allowing Complex to implement INumber also means various other interfaces have to get moved out and represented another way. Formatting still makes sense, but parsing becomes somewhat problematic when two parts/elemenents are at play. Comparisons no longer make sense and so you lose out on sorting, you lose out on Min/Max, you lose out on clamping. While these are concepts that make sense for almost any other number type that is representable by a single logical part (regardless of how many fields its represented by).

It also brings in complexity around versioning. Things like Clamp only came about in .NET Core 3.1 and are easily defineable in terms of Min/Max and comparison operators. Likewise, the same is true of many other primitive operations when referring to a value as a single logical part. You can define creation, conversion, parsing, absolute values, etc fairly trivially given these constraints. They all go away once you bring in a second part to the underlying value because the considerations around the other concepts all change.


To me, the changes being asked for above significantly reduces the usefulness of INumber<T> for the average consumer of types. The tradeoff is that it becomes simpler for implementors of the interface and allows them to expose the interface on more types, many of which may be internal and not ever actually user visible.

I do not believe this trade-off is worthwhile. Now, with that being said, there may be some other name we could use for INumber<T> to help clarify the usage and intent and likewise there might be some lesser interface that could be exposed that meets the asks being given, but I do not believe such an interface will be commonly used by application or service authors. I believe they will generally be using INumber or IBinaryNumber and will most commonly be dealing with types that implement these interfaces.

@eiriktsarpalis
Copy link
Member

[Complex is] even unlike user-defined types that can be represented by a single logical set of contiguous bits, but may for many reasons represent themselves as multiple fields (BigInteger, Rational, etc).

Complex is fundamentally a data type of two distinct parts, one real (a) and one imaginary (b) representing a + bi. It could decide to represent these parts many different ways, but fundamentally it is two distinct units combined into a single type.

Could you clarify a bit more what you mean by this distinction? If this is not a SIMD/hardware support concern, what would prevent a complex number from being represented as a "single logical set of contiguous bits" and why is the bit-by-bit representation important from the perspective of a numeric abstraction? Rationals are also represented using two distinct "parts", and the same can be said about floating point numbers.

@tannergooding
Copy link
Member Author

tannergooding commented Nov 22, 2021

Rationals are also represented using two distinct "parts", and the same can be said about floating point numbers.

Let me try to put it into math terms.

Integers, rational numbers, and floating-point numbers all represent Real numbers. Complex numbers on the other hand, despite being numbers are not Real, they are Complex. They are distinctly represented as an expression of two Real numbers a, b, and the imaginary unit i as: a + bi and almost any arithmetic involving these has to consider both of the Real numbers involved to do anything accurately or correctly.

This distinctly makes them different from other numbers that programmers or even the general population needs to consider and/or work with. They are an important, but rarely used subsection of the ecosystem that is relegated to specialized scenarios and I don't believe that trying to model the "primary" number systems in these interfaces is good or correct as it will likely lead to confusion and usability issues for the average developer who may not be familiar with terms like Complex, Real, or Natural (unsigned) numbers.

@tannergooding
Copy link
Member Author

Where-as Natural, Integer, Rational, and Real numbers all follow effectively the same general rules and ordering characteristics; including the standard characteristics around things like commutativity, ordering, equality, unary and binary operations, etc.

Complex numbers break the mold here and change up a number of the rules and considerations. You need to start considering conjugates and both real parts, you start losing out on total ordering, additional properties and other oddities that don't exist lower in the stack.

It would also take the most obvious name INumber that people are going to be looking for when wanting to deal with these interfaces and exchanging it for something that would largely only exist to support very advanced domain specific scenarios. Moving the thing that most beginners and non-domain specific programmers want to a more esoteric name instead.

@JimMcInerney
Copy link

I think the more important distinction for Complex is that it is an unordered field, rather than the fact that it has two components. Other numeric types that would require multiple components, such as rationals or quadratic fields are still ordered and can implement INumber quite naturally.

I implemented my own Complex struct and had it implement INumber as currently defined, throwing NotImplementedExceptions for the operations that don't make sense. This worked and performed virtually identically with System.Numerics.Complex, but it's a kludge.

I'm interested in some linear algebra applications that would benefit from creating generic matrices of either real (represented by double) or complex entries. As it is, to get this effect I have to specify some 9 or so interfaces, including IAdditionOperators, IMultiplyOperators, ISubtractionOperators, etc.

Personally, I would like to see INumber restricted to the interfaces that make sense for Complex (i.e. the operations on an unordered field), and then have a derived interface like IOrderedNumber for all the types such as int, double, etc., which covers ordered fields.

I won't make an argument about including the formatting and parsing interfaces. Those do make sense whether the number is from an ordered or unordered field, so it's more a matter of taste whether to include them or not.

One final comment, the documentation for System.Numerics.Complex frequently and repeatedly calls it a number. It does seem like a violation of the Principle of Least Surprise, to then say well, it's a number, but not an INumber.

@tannergooding
Copy link
Member Author

tannergooding commented Nov 22, 2021

One final comment, the documentation for System.Numerics.Complex frequently and repeatedly calls it a number. It does seem like a violation of the Principle of Least Surprise, to then say well, it's a number, but not an INumber.

I can definitely agree on this and we can update various documentation accordingly.


I'd also like to reiterate that I am amenable to exposing some common interface to fit the needs here.

However, I strongly believe that changing the current interface away from INumber to something else (including things like IOrderedNumber or IRealNumber) is going to end up causing more confusion for the general community in favor of less confusion for a domain-specific area of the community.

I'd suggest the interested parties propose, in tandem, two interface names here:

  1. A "base" interface that fits the set of Complex, Real, Rational, Integer, and Natural numbers; currently undefined
  2. The "core" interface that most users will end up using comprising Real, Rational, Integer, and Natural numbers; currently defined as INumber

The second interface needs to be the obvious default choice for the general/non-domain specific users. INumber currently fits that bill and I expect will be the first thing non-domain specific users look for when going "I need to work with numbers".

The name of the first interface I have less concerns with, we could even call it INumberBase and I think that wouldn't be "too bad". That would put it SxS with INumber and give it a name that follows a somewhat existing convention (such as Button vs ButtonBase in WPF) and the domain-specific users would likely be able to figure out what it means; particularly if the documentation calls out some of the domain-specific terminology in the summary or remarks section.

@eiriktsarpalis
Copy link
Member

Integers, rational numbers, and floating-point numbers all represent Real numbers. Complex numbers on the other hand, despite being numbers are not Real, they are Complex. They are distinctly represented as an expression of two Real numbers a, b, and the imaginary unit i as: a + bi and almost any arithmetic involving these has to consider both of the Real numbers involved to do anything accurately or correctly.

In math terms, integers is a subset of floating point numbers, which is a subset of rational numbers, which is a subset of real numbers, which is a subset of complex numbers. Honestly the a + bi normal form does not introduce any fundamental differentiation in the nature of their number-ness. The normal form for rationals also requires two integers, but we're not drawing a similar distinction in that case.

Complex numbers break the mold here and change up a number of the rules and considerations. You need to start considering conjugates and both real parts, you start losing out on total ordering, additional properties and other oddities that don't exist lower in the stack.

That I can agree with. Ultimately it depends on whether we want to support things like comparison and magnitudes OOTB in the INumber interface.

This distinctly makes them different from other numbers that programmers or even the general population needs to consider and/or work with. They are an important, but rarely used subsection of the ecosystem that is relegated to specialized scenarios

Ultimately that depends on what customers we are trying to reach. This is likely true for the current .NET ecosystem, but I would dispute that it's rarely used if we're looking at catering to scientific computing applications.

@JimMcInerney
Copy link

Personally, I would be happy with a "base" number interface that included essentially the operations for an unordered field, and which Complex and all the usual numeric types complied with.

@tannergooding
Copy link
Member Author

tannergooding commented Nov 22, 2021

Ultimately that depends on what customers we are trying to reach.

This is explicitly a feature targeting .NET as a whole. If it were being designed for domain specific usage, it would have a different design and would not be in the System namespace.

I would dispute that it's rarely used if we're looking at catering to scientific computing applications.

Yes, if you go and look at a domain-specific industry then domain-specific terms and types become commonplace.

Ultimately it depends on whether we want to support things like comparison and magnitudes OOTB in the INumber interface.

These are going to be supported. These are basic operations that I believe the majority of the .NET users will expect exists and will think of as being available when you tell them "you can work with number types via generics".

In math terms

Yes, but we aren't targeting math users and that's one of the reasons I've tried to avoid putting these into math terms.

We are targeting .NET programmers where the vast majority of developers are writing things like ASP.NET Web Sites/Services, WinForms/WPF/Line-of-Business apps, or making games in Unity.

Math is an extremely specialized field and you can endlessly go down the rabbit hole of what is or isn't. If you asked the average developer, let alone the average person, if 0.999... == 1 you'd probably be hit or miss based on whether or not they'd seen the question before and not because they had gone and studied the concept or the proof around it. If you asked them what PI is, they'd probably say 3.14 or maybe 3.14159 if they memorized a few extra digits.

At the end of the day, and for many reasons, Computer Math != Real Math. The average programmer likely does not have a degree in math and does not understand domain-specific terminology or concepts. The APIs being designed and exposed here have to account for that, as do any APIs we design and expose in .NET.

@eiriktsarpalis
Copy link
Member

Yes, but we aren't targeting math users
We are targeting .NET programmers
Math is an extremely specialized field and you can endlessly go down the rabbit hole of what is or isn't.
At the end of the day, and for many reasons, Computer Math != Real Math.

These are all fairly self-evident observations, but I'm not sure how they relate to the discussion at hand. At the end of day I'm making sure that any design trade-offs are made for the right reasons (and you did give quite a few valid ones here), but I will insist that the argument about a + bi forms requiring special treatment does not meet any standard interpretation of complex numbers either in math or CS that I'm aware of.

@tannergooding
Copy link
Member Author

tannergooding commented Nov 22, 2021

but I will insist that the argument about a + bi forms requiring special treatment does not meet any standard interpretation of complex numbers either in math or CS that I'm aware of.

I disagree and I don't know how to put into words that will give you the necessary insight into why I believe that's the case.

Complex numbers are fundamentally different from the numbers or number representations that developers typically work with and they require additional consideration and special treatment when working with them.

System.Numerics.Complex isn't just some INumberBase, its some:

interface IComplexNumber<TSelf, TElement> : INumberBase
    where TSelf : IComplex<TSelf, TElement>
    where TElement : INumber<TElement>
{ }

It is its own unique thing in the same way that IBinaryNumber, IBinaryInteger, and IBinaryFloatingPoint must be their own things.

Some Rational number type would likewise have some special considerations that would warrant it having its own interface.

Part of this comes down to representation and part of it comes down to programs fundamentally needing to be able to create instances of a type and convert between types.

@eiriktsarpalis
Copy link
Member

eiriktsarpalis commented Nov 22, 2021

part of it comes down to programs fundamentally needing to be able to (...) convert between types.

Does this concern converting, say, i to a double? If so, would it be substantially different from converting double.NaN to an int?

@terrajobst
Copy link
Member

Can we merge part of this? We just shipped and leaving such as huge design document open in PR for so long doesn't really help with making progress/catching up with changes.

@tannergooding tannergooding merged commit adaa057 into dotnet:main Nov 22, 2021
@tannergooding
Copy link
Member Author

Merged. I'll get a new PR up with the .NET 7 changes here at some point in the near future.

Further discussion can happen on that thread.

@tannergooding
Copy link
Member Author

I've opened #257 which I'll be updating to cover discussed changes, etc.

As of right now, it only covers splitting out INumber into INumber and INumberBase. I've left some comments on the proposal and would appreciate if those that were interested and commenting above (CC. @charlesroddie, @eiriktsarpalis, @JimMcInerney, etc).

@tannergooding
Copy link
Member Author

tannergooding commented Mar 17, 2022

Coalescing the two prior API review notes for convenience


#205 (comment)

Video

  • Should we eliminate IParseable and just add ISpanParseable?
  • Rename all lhs parameters to left and all rhs parameters to right
  • We need a consistent name for the (low-level) operator defining interfaces.
    • I[Purpose]Operators seems the most reasonable, with Purpose being the primary metadata name, or concept
    • IEquatableOperators => IEqualityOperators
    • IComparableOperators => IComparisonOperators
    • IAddable => IAdditionOperators
    • ISubtractable => ISubtractionOperators
    • IRemainder => IModulusOperators
    • INegatable => IUnaryNegationOperators
    • IShiftable => IShiftOperators
  • We need a consistent name for the value-exposing interfaces (MinValue/MaxValue)
    • (Maybe they're the best they're going to be?)
    • IAdditiveIdentity =>
    • IMultiplicativeIdentity =>
    • IMinMaxValue =>
  • INumber we discussed, and feel it's probably the right name
  • INumber.Create* feels a little weird. Is it the right level? Is there a good way to help convey what TOthers do and don't work?
    • Should they be a different interface, higher or lower level?
  • BinaryInteger.Rotate* second parameters should be TSelf, not int.
  • IBinaryNumber and beyond were only skimmed
  • IBinaryNumber's methods probably want some changes (@bartonjs has some regrets about "isUnsigned", at least).

#205 (comment)

Video

Notes from today's review:

  • We'd like to keep the split of IParseable<TSelf> and ISpanParseable<TSelf> because spans are more advanced and we don't want to force span processing on everyone
    • However, if there is a limitation on number of interfaces, then this is a good candidate to collapse
    • There is also an argument one could make that span is a core concept and we generally want type providers to support it moving forward and collapsing would make that mandate more explicit. However, this might have implications on languages that don't support spans.
  • We should extract the Create methods to an IConvertible<TSelf>, akin to IParseable<TSelf>
    • This would make conversion of types without boxing available outside of generic math and not be limited to numbers
    • We should probably use the Convert verb, rather than Create
  • For converting, we have three behaviors when the value doesn't fit:
    1. Truncating/wrapping
    2. Saturating
    3. Throwing
  • It seems we can't agree on the default, so it seems we should have three differently named methods:
    • CreateTruncated
    • CreateClamped
    • CreateChecked
  • We talked about which members we'd implement explicitly vs implicitly
    • We're generally OK with implementing most implicitly, which would increase the API surface of the primitive types, but that seems fine
    • However, we should implement members explicitly that are nonsensical or aren't needed outside of a generic context, such as most of the Create methods

@JesOb
Copy link

JesOb commented Jun 9, 2022

I found that INumber Parse uses String as argument

Can we go out of String in regard to ReadOnlySpan< Char > and/or ReadOnlySpan< Byte or Utf8String > to not allocate Strings where possible just for number parsing

@tannergooding
Copy link
Member Author

INumber and IParsable should be providing overloads that take both string and ROSpan<char>.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.