-
-
Notifications
You must be signed in to change notification settings - Fork 17
Add "Generic Numeric Code" pattern #114
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
Open
SeanTAllen
wants to merge
6
commits into
main
Choose a base branch
from
add-generic-numeric-code-pattern
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
d7c2132
Add "Generic Numeric Code" pattern
SeanTAllen 1e392e1
Replace "obvious approach" with less patronizing phrasing
SeanTAllen b6cdf5c
Tweak wording
SeanTAllen b217a32
Remove streaming llmstxt fix (moved to separate PR #115)
SeanTAllen a9a640b
Remove SKILL.md change (moved to separate PR #116)
SeanTAllen c61a61d
Add Generics Patterns to landing page
SeanTAllen File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ backpressure | |
| MessagePack | ||
| msgpack | ||
| RESP | ||
| signedness | ||
| unrepresentable | ||
| Wadler | ||
| Zulip | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| --- | ||
| hide: | ||
| - toc | ||
| --- | ||
|
|
||
| # Generic Numeric Code | ||
|
|
||
| ## Problem | ||
|
|
||
| You want to write a class that works with any unsigned integer type. A block cipher component, say, that stores a value and provides parity checking and XOR operations. Your first instinct might be to use `Unsigned` as the type constraint: | ||
|
|
||
| ```pony | ||
| class Block[T: Unsigned] | ||
| var data: T | ||
|
|
||
| new create(data': T) => | ||
| data = data' | ||
|
|
||
| fun parity(): Bool => | ||
| (data.popcount() % 2) == 1 | ||
|
|
||
| fun ref apply_xor(o: Block[T]) => | ||
| data = o.data xor data | ||
| ``` | ||
|
|
||
| This doesn't compile. You'll get errors on the `%`, `==`, and `xor` operations, plus the numeric literals `2` and `1`. The constraint looks right, but `Unsigned` alone isn't enough to make generic numeric code work in Pony. | ||
|
|
||
| ## Solution | ||
|
|
||
| Two things need to change. First, the type constraint needs to be `(Unsigned & UnsignedInteger[T])` instead of just `Unsigned`. Second, numeric literals need to be wrapped with `T.from[U8](n)`. | ||
|
|
||
| Let's start with the constraint. `Unsigned` in Pony is a union type containing all the built-in unsigned integers: `U8`, `U16`, `U32`, `U64`, `U128`, `ULong`, and `USize`. When you use only this union as a constraint, the compiler has to consider that `T` could be instantiated with a union like `(U8 | U16)`. That's a problem because you can't XOR a `U8` with a `U16`. Binary operations need both operands to be the same type. | ||
|
|
||
| Adding `UnsignedInteger[T]` to the constraint fixes this. `UnsignedInteger` is a trait that guarantees its type parameter can do binary operations, arithmetic, and comparisons with another value of the same type. By constraining `T` to be both `Unsigned` and `UnsignedInteger[T]`, you're telling the compiler: `T` must be one of the concrete unsigned integer types, and it must support operations with other values of exactly type `T`. | ||
|
|
||
| ```pony | ||
| class Block[T: (Unsigned & UnsignedInteger[T])] | ||
| ``` | ||
|
|
||
| The second fix is numeric literals. Pony can't create a number literal for a generic type. When the compiler sees `2`, it needs to know the concrete type right then. In non-generic code that's inferred from context, but inside a generic function the compiler only knows `T`, not whether it's `U8` or `U64`. | ||
|
|
||
| The workaround is `T.from[U8](n)`. This tells the compiler the literal `n` is a `U8`, then converts it to whatever `T` turns out to be: | ||
|
|
||
| ```pony | ||
| fun parity(): Bool => | ||
| (data.popcount() % T.from[U8](2)) == T.from[U8](1) | ||
| ``` | ||
|
|
||
| Since we're dealing with constant values, LLVM trivially optimizes away the conversion. There's no runtime cost. | ||
|
|
||
| Putting it all together: | ||
|
|
||
| ```pony | ||
| class Block[T: (Unsigned & UnsignedInteger[T])] | ||
| var data: T | ||
|
|
||
| new create(data': T) => | ||
| data = data' | ||
|
|
||
| fun parity(): Bool => | ||
| (data.popcount() % T.from[U8](2)) == T.from[U8](1) | ||
|
|
||
| fun ref apply_xor(o: Block[T]) => | ||
| data = o.data xor data | ||
|
|
||
| actor Main | ||
| new create(env: Env) => | ||
| let a = Block[U32](0b1011) | ||
| let b = Block[U32](0b1100) | ||
| env.out.print("a parity (odd number of 1s): " + a.parity().string()) | ||
| env.out.print("b parity (even number of 1s): " + b.parity().string()) | ||
| ``` | ||
|
|
||
| You can instantiate `Block[U8]`, `Block[U32]`, `Block[U128]`, or any other concrete unsigned integer type, and the compiler generates specialized code for each. | ||
|
|
||
| ## Discussion | ||
|
|
||
| The same pattern works for signed integers and for all integers. For signed types, use `(Signed & Integer[T])`. For code that works with any integer regardless of signedness, use `(Int & Integer[T])`. You might expect `SignedInteger[T]` to mirror `UnsignedInteger[T]`, but `SignedInteger` takes two type parameters (the signed type and its unsigned counterpart), making it awkward to use in constraints. `Integer[T]` is simpler and is what the standard library uses. The standard library follows this convention throughout. `Format.int` uses `[A: (Int & Integer[A])]`, and `String.read_int` uses `[A: ((Signed | Unsigned) & Integer[A] val)]`. Whenever you see a generic function constrained to a numeric type in the standard library, you'll find this intersection pattern. | ||
|
|
||
| The reason `Unsigned` alone doesn't work comes down to how Pony's type system handles union types as constraints. A constraint like `[T: Unsigned]` means `T` can be any subtype of `Unsigned`. Since `Unsigned` is a union, `(U8 | U16)` is a valid subtype of it, so `Block[(U8 | U16)]` would be a legal instantiation. With a union as `T`, binary operations between two values of type `T` could end up mixing a `U8` and a `U16`, which Pony doesn't allow. The `UnsignedInteger[T]` trait constrains `T` to a single concrete type that supports operations with itself, ruling out union instantiations. | ||
|
|
||
| The `T.from[U8](n)` pattern for literals deserves a closer look. `U8` is the natural choice for the source type because it's the smallest unsigned integer, and numeric literals used in generic code tend to be small constants (0, 1, 2, bitmasks). You could use any integer type as the source, but `U8` keeps the intent clear: this is a small constant being widened to whatever `T` is. The `from` method is part of the `Integer` trait, so it's available on any type that satisfies the constraint. | ||
|
|
||
| If you're reaching for generic numeric code because of performance concerns around boxing, the [Avoid Boxing with Parameterization](../performance/avoid-boxing.md) pattern covers that angle. Boxing happens when a primitive value gets passed where any type is expected (`Any val` or a union parameter), forcing a heap allocation. Using type parameters avoids boxing because the compiler knows the concrete type at each call site. The constraint patterns described here and in that pattern are the same; the difference is the motivation. Here the focus is on making generic numeric code compile correctly. There the focus is on eliminating unnecessary heap allocations. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| --- | ||
| hide: | ||
| - toc | ||
| --- | ||
|
|
||
| # Generics Patterns | ||
|
|
||
| Pony's generics let you write code that works across multiple types without duplicating logic. But the type system has rules that aren't always obvious, especially around numeric types and capabilities. These patterns cover the common situations where a straightforward generic approach doesn't compile and show you how to fix it. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This explains why the union is insufficient, and the trait is necessary, but a reader might then wonder why the trait is insufficient, and the union is necesary.
The answer boils down to: Pony only accepts built-in numeric types for number literals in source code, so we need the type union part of the constraint to ensure that number type is one of the known built-in numeric types.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually as we continued discussing, I'm unsure about why the type union is required - the example in this pattern compiles without it.
Also I went and checked other examples from other Zulip threads which all compile without a trait-only constraint (provided that the trait gets suffixed with
val, which was implicit before when it was intersected with aval-only union).I seem to remember that unions are required in some cases, but I don't know when/where/why.
Maybe you/Claude could try looking at stdlib places where the type union is part of the constraint, and try removing it, and seeing if the code can be made to work without that.
If we have any places that work fine without the union being part of the constraint, I believe it should be removed, because it's better to allow for as many safe cases as possible, loosening the constraint where we can.
If there are places that don't work without the union, we should use one as an example to explain here in the pattern, so that others including me will know when/why it's needed.