Skip to content

Add type restrictions to big#15689

Merged
straight-shoota merged 5 commits intocrystal-lang:masterfrom
Vici37:add-type-restrictions-to-big
Aug 15, 2025
Merged

Add type restrictions to big#15689
straight-shoota merged 5 commits intocrystal-lang:masterfrom
Vici37:add-type-restrictions-to-big

Conversation

@Vici37
Copy link
Contributor

@Vici37 Vici37 commented Apr 20, 2025

This is the output of compiling cr-source-typer and running it with the below incantation:

CRYSTAL_PATH="./src" ./typer spec/std_spec.cr --error-trace --exclude src/crystal/ --stats --progress --union-size-threshold 2 --ignore-private-defs src/big

This is related to #15682 .

# BigDecimal.new(1).div(BigDecimal.new(3), 5) # => BigDecimal(@value=33333, @scale=5)
# ```
def div(other : BigDecimal, precision = DEFAULT_PRECISION) : BigDecimal
def div(other : BigDecimal, precision : UInt64 | Int32 = DEFAULT_PRECISION) : BigDecimal
Copy link
Contributor

Choose a reason for hiding this comment

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

I think any unions exclusively consisting of integral types should just be Int::Primitive

There's no real reason for these two types to be mentioned explicitly

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh, good call, will make that change.

Copy link
Member

Choose a reason for hiding this comment

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

I don't think Int::Primitive is a good type for that. Most of the time it should be either Int or the biggest supported integer type (making use of autocasting).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I can't think of a reason where a negative value would be provided, so UInt64?

Copy link
Member

Choose a reason for hiding this comment

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

Negative values might not make sense, but even a positive value can be of a signed integer type. And that wouldn't autocast to an unsigned one.
Int should be fine here.

# Factors out any extra powers of ten in the internal representation.
# For instance, value=100 scale=2 => value=1 scale=0
protected def factor_powers_of_ten
protected def factor_powers_of_ten : UInt64?
Copy link
Contributor

Choose a reason for hiding this comment

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

It's a protected method whose result is never used, should be : Nil

end

def <=>(other : Float::Primitive)
def <=>(other : Float::Primitive) : Int32?
Copy link
Contributor

Choose a reason for hiding this comment

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

All the <=> methods should normally just return Int32

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agreed, and the Nil return is a bit confusing here as well (though not incorrect when considering you're comparing with a NaN).

Copy link
Contributor

Choose a reason for hiding this comment

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

All the <=> methods should normally just return Int32

The contract in general is to return nil when not comparable. That is relevant for some types, but not all.

Copy link
Contributor

@HertzDevil HertzDevil Apr 23, 2025

Choose a reason for hiding this comment

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

This Float::Primitive overload was indeed added as a breaking change in 1.9 because it needs to return nil. Note that BigFloat itself doesn't have NaN, since we are still using GMP, not MPFR

self
end

private def mpq
Copy link
Contributor

Choose a reason for hiding this comment

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

Why are some of these functions not typed at all?

Should be : LibGMP::MPQ*

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The cr-source-typer has a new flag to ignore private defs, as well as type restrictions that are resolved to 3+ union types.

I realize my PR description doesn't show that with the single line command 😬

end

def self.default_precision
def self.default_precision : UInt64
Copy link
Collaborator

Choose a reason for hiding this comment

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

The LibGMP method actually returns a LibGMP::BitcntT => LibGMP::UI => LibC::ULong, so the method returns a target dependent integer.

I think this outlines of the tool: it infers types for a specific target. It also exposes an issue in the stdlib: it leaks target dependent types.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Any recommendations on what this line should be? I can remove the return statement here, and agreed about the rest of your statement about leaking target dependent types.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, I see the failed build as a consequence of this change. I'll remove the return type at least.

Copy link
Member

Choose a reason for hiding this comment

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

Instead of looking at the type of an expression in the current program, the typer tool could also apply some more generic type inference based on restrictions of the source/sink of these expressions.
In this case, it's pretty obvious that the return type of this method should be equal to the type restriction of LibGMP.mpf_get_default_prec, which is LibGMP::BitcntT.

Going this way ensures using the correct, target-independent aliases.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Makes sense - probably more work than is justified for this kind of edge case, but doing more inspection up and down the call-chain has been something I've been curious to do (for additional static code analysis type things).

end

def self.default_precision=(prec : Int)
def self.default_precision=(prec : Int) : Nil
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is worrysome: a setter method should always return its argument.

Copy link
Contributor

Choose a reason for hiding this comment

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

There is the older and abandoned #10083 for this

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My own 2-cents: it should be a linting rule rather than a language rule, that the setter return what is handed to it. Seems there are other opinions on what setter methods should return, too (like matching the getter).

Copy link
Member

Choose a reason for hiding this comment

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

Yes, we'd want this to be different. But this change is fine for this PR.

Copy link
Contributor

Choose a reason for hiding this comment

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

You want it to return what was passed in so that chained assignment is handled. Ruby, for example, ignores returning a value from setters for this reason.

PRECISION = BigFloat.default_precision = 5

Copy link
Member

Choose a reason for hiding this comment

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

@Fryguy Yes, this is tracked in #7707.

end

def self.default_precision=(prec : Int)
def self.default_precision=(prec : Int) : Nil
Copy link
Member

Choose a reason for hiding this comment

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

Yes, we'd want this to be different. But this change is fine for this PR.

# BigDecimal.new(1).div(BigDecimal.new(3), 5) # => BigDecimal(@value=33333, @scale=5)
# ```
def div(other : BigDecimal, precision = DEFAULT_PRECISION) : BigDecimal
def div(other : BigDecimal, precision : Int = DEFAULT_PRECISION) : BigDecimal

Choose a reason for hiding this comment

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

This comes from way before this PR, but I would have expected precision to be a BigInteger in BigDecimal 🤔

Choose a reason for hiding this comment

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

FWIW, bigdecimal-rs, which this implementation is based on, does the same

@straight-shoota straight-shoota added this to the 1.18.0 milestone Aug 12, 2025
@straight-shoota straight-shoota merged commit 357d469 into crystal-lang:master Aug 15, 2025
41 checks passed
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.

8 participants