-
Notifications
You must be signed in to change notification settings - Fork 17.7k
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
spec: a general approach to type inference #58650
Comments
The untyped constants bit has never made me happy. It seems like Maybe it would help to keep the untyped type around a bit longer, like: NewPair(1, 2.5)
That would only make more things unify so it can be added to the list of possible extensions but it may be easier to work it in from the start. |
@jimmyfrasche Thanks for the comment, but I don't agree. I think that |
Very nice proposal.
Is the claim that the proposed inference is a relaxation of the current spec? You gave an example where a program that is not allowed today would become allowed; do you have examples where legal programs today would fail inference or where the inferred types would change? The proposal doesn't explicitly address backward compatibility. |
I don't have to write |
@cespare Yes; the claim is that all programs that compile today continue to compile. It is intended to be completely backward compatible. @jimmyfrasche Fair enough. I'm not sure I agree, but in any case this proposal reflects how type inference already works today, so perhaps that discussion should move to a separate issue. Thanks. |
For "Inferring based on assignment context"
May I suggest adding something like |
To confirm, do you anticipate any meaningful tool speed impact from this? (Either algorithmic or implementation details.) |
I'm not expecting any effect on compilation speed. |
Change https://go.dev/cl/470916 mentions this issue: |
While I like the idea proposal, I think the type inference algorithm should be explained in more simple terms to make it more teachable to new Go programmers or to those who are new to generics. I tried to read this proposal but I was too complicated for me. |
Well, this is intended to be the precise version. I'm open to suggestions for how to make it more teachable. I do at least find this proposal to be simpler than C++ template argument deduction. |
Sure, but anything in computing is easier to understand than C++. Except for Haskell maybe. As for making it more teachable, there are some concepts like "unification" in this proposal as well as some one letter abbreviations. These could probably be explained better. |
@beoran I'm reasonably confident that if this is the path forward, we'll see some good pedagogical materials come out (possibly from a third party), either as a blog post or a GopherCon talk, with pretty visuals and intuition pumps. Insofar as I understand it (and tbh I haven't looked all that closely), the proposal isn't so complex as to defy explication. And my own personal experience with type inference when authoring regular Go code remains:
|
I feel like I've hit some different rabbit holes. For example, with different variations of the "Inferring using assignment context type parameters" example, thinking about when the order of applying The universe of possible inferences rules seems usefully bounded by the proposed algorithm, especially regarding compile-time complexity ... I like that the proposal distinguishes this from particular subsets of rules that might feel more or less clear in practice. |
The primary change is that type inference now always reports an error if a unification step fails (rather than ignoring that case, see infer2.go). This brings the implementation closely to the description in #58650; but the implementation is more direct by always maintaining a simple (type parameter => type) mapping. To make this work, there are two small but subtle changes in the unifier: 1) When deciding whether to proceed with the underlying type of a defined type, we also use the underlying type if the other type is a basic type (switch from !hasName(x) to isTypeLit(x) in unifier.go). This makes the case in issue #53650 work out. See the comment in the code for a detailed explanation of this change. 2) When we unify against an unbound type parameter, we always proceed with its core type (if any). Again, see the comment in the code for a detailed explanation of this change. The remaining changes are comment and test adjustments. Because the new logic now results in failing type inference where it succeeded before or vice versa, and then instatiation or parameter passing failed, a handful of error messages changed. As desired, we still have the same number of errors for the same programs. Also, because type inference now produces different results, we cannot easily compare against infer1 anymore (also infer1 won't work correctly anymore due to the changes in the unifier). This comparison (together with infer1) is now disabled. Because some errors and their positions have changed, we need a slightly larger error position tolerance for types2 (which produces less accurate error positions than go/types). Hence the change in types2/check_test.go. Finally, because type inference is now slightly more relaxed, issue #51139 doesn't produce a type unification failure anymore for a (previously correctly) inferred type argument. Fixes #51139. Change-Id: Id796eea42f1b706a248843ad855d9d429d077bd1 Reviewed-on: https://go-review.googlesource.com/c/go/+/470916 Reviewed-by: Robert Griesemer <gri@google.com> TryBot-Result: Gopher Robot <gobot@golang.org> Reviewed-by: Robert Findley <rfindley@google.com> Run-TryBot: Robert Griesemer <gri@google.com> Auto-Submit: Robert Griesemer <gri@google.com>
Based on @griesemer 's work in the compiler, I've added a new clause to the "Adding entries" section:
|
Adding to minutes; top comment far above is up to date with the current proposal. |
This proposal has been added to the active column of the proposals project |
Based on the discussion above, this proposal seems like a likely accept. |
No change in consensus, so accepted. 🎉 |
Change https://go.dev/cl/499282 mentions this issue: |
For #39661. For #41176. For #51593. For #52397. For #57192. For #58645. For #58650. For #58671. For #59338. For #59750. For #60353. Change-Id: Ib731c9f2879beb541f44cb10e40c36a8677d3ad4 Reviewed-on: https://go-review.googlesource.com/c/go/+/499282 TryBot-Bypass: Robert Griesemer <gri@google.com> Reviewed-by: Ian Lance Taylor <iant@google.com> Reviewed-by: Robert Griesemer <gri@google.com>
Marking as a release-blocker to track the pending changes to the spec document. This does not have to block any RCs. |
The implementation is complete. The spec update is essentially done. Just tweaking the details. I don't think we need to leave this issue open. |
Not sure where else to post this, but this GoLang Nuts discussion shows another area where additional type inference would be nice. I believe this is an example of Inferring based on interfaces mentioned above. |
This proposal, by both me and @griesemer, describes a general approach to type inference. The goal is to replace the current use of two different kinds of type inference, to make type inference more easily extensible. We describe several possible extensions.
Background
Type inference takes a reference to a generic function that does not explicitly specify all the type arguments and infers the type arguments to use based on other information from the program. The most common example of that other information is the arguments passed in a call to the generic function.
Type inference is only attempted when it is required; if type inference fails, the program is invalid.
If type inference succeeds, the inferred type arguments must still be validated against the type parameter constraints, as though they were specified explicitly. Type inference may succeed with type arguments that are not actually permitted.
Type inference must be independent of the order in which type arguments and actual arguments appear in the program. We do not want rearranging the order of arguments to a function to affect which argument types are inferred.
Proposal
This proposal describes a single framework for type inference, replacing the two distinct forms of type inference we have today. The goal is to provide a clean way to extend type inference in the future.
After the proposal proper we discuss a series of possible extensions.
This proposal uses type unification and an inference map from type parameters to (potential) type arguments.
Naming
In this proposal F is a generic function, P is a type parameter, C is a constraint, D is the core type of a constraint, Q is the type of a regular function parameter, A is a type argument, R is the type of an actual argument, U is an untyped constant, I is a named type, T is a type literal (a type other than a simple named type), X is any type.
When inferring types it is necessary to distinguish different parameters, constraints, and types, even if they happen to have the same name in the program. In the example below we consistently use different names for all identifiers for clarity, but in real programs it is common for many names to be reused (for example, many functions have a type parameter named
P
orT
).Type unification
Type unification is much like type unification in the current type inference algorithm. It applies to two different types and may update entries in the inference mapping.
When unifying two types that do not contain any type parameters, unification succeeds if:
When unifying types that contain type parameters, unification compares the structure of two types. When unifying a defined type I with a general type T, unification uses the underlying type of I. When unifying a type parameter P with a general type T, where P is not one of the type parameters that we are trying to infer (perhaps because it is a type parameter of the calling function), unification uses the core type of P's constraint, if it has one. The structure of the two types, disregarding type parameters, must be identical, and types other than type parameters must be identical. A type parameter P in one type may match any complete component type X in the other type; each successful match will add P => X to the inference map. If the structure is different, or if types other than type parameters are not identical, then unification fails.
Unifying two different types X1 and X2 produces a unified type X3, which will be used when updating the inference map. Where X1 and X2 are the same, X3 is also the same. Where X1 and X2 differ, X3 is determined as follows:
MyInt
andint
, X3 will haveMyInt
Inference map
The algorithm is attempting to infer the type arguments for a known set of type parameters. The inference mapping records the current state for each type parameter.
Type unification can add a mapping P => X1 to the inference map. If there is no current entry for P in the map, then the compiler simply adds P => X1 to the map. If there is already a current entry P => X2, then X1 and X2 are themselves unified (which may in turn cause other entries to be added to the inference map). If unification succeeds, then the mapping is updated to become P => X3, where X3 is the unification of X1 and X2.
Type inference algorithm
Type inference applies when a generic function is called, and the number of explicitly specified type arguments (which may be zero) is less than the number of type parameters. Type inference uses the explicitly stated type arguments (if any) and the arguments to the function call to infer the rest of the type arguments.
For variadic functions that are called with a series of values, rather than with a slice followed by
...
, type inference acts as though the final parameter type is repeated zero or more times corresponding to the number of values passed in the function call.Type inference adds mappings to the inference map and unifies types based on the actual function call. Adding a mapping P => X to the inference map can cause a type unification, if there is already a mapping for P to some different type. Doing any type unification can in turn cause new entries to be added to the inference map. The order in which these operations are done does not matter. Type inference completes when all mappings have been added and all type unifications have completed. At that point, type inference succeeds if every type parameter has a type argument that does not itself contain any type parameters.
Adding entries
For a partial instantiation F[A, ...] (where F has more type parameters than there are A's), we add a mapping P => A for every explicitly specified type argument.
For a function call F(arg, ...) where some arg is not an untyped constant and has type R, and where the type of the corresponding parameter is a type parameter P, we add a mapping P => R.
For a function call F(arg, ...) where some arg is not an untyped constant and has type R, and where the type of corresponding parameter is a type parameter P, and where P's constraint C has some methods, we unify the methods of R with the methods of C.
For a function call F(arg, ...) where some arg is not an untyped constant and has type R, and where the type of the corresponding parameter, Q, is not a type parameter but contains one or more type parameters, we unify Q and R.
When calling a function defined as F[P C, ...] where the constraint C has a core type but is not an underlying type (~C), we add a mapping P => D, where D is the core type of C.
When calling a function defined as F[P ~C, ...] where the constraint is an underlying type with a core type, we add a mapping P => ~D, where D is the core type of C.
After all of those entries are made and all unifications are completed, we consider any untyped constants in the argument list. Each such untyped constant must correspond to a function parameter whose type is simply a type parameter P, as an untyped constant can never be assigned to a composite type. If P is not currently mapped to a type argument in the inference map, we add a new mapping P => I where I is the default type of the untyped constant. Note that it is possible that P is used for multiple function parameters, and as such this step may cause multiple mappings to be added for P, in which case those mappings are unified as usual.
Completion
After all entries have been added, and all unifications completed without errors, check that the inference map has an entry for every type parameter. If not, type inference fails. Then for each type parameter, if the mapping refers to any other type parameters, replace those type parameters with their own mappings, recursively. If there are any cycles, type inference fails. If the resulting mappings have any irresolvable type parameters, type inference fails. Otherwise, every type parameter is mapped to some concrete type that does not contain any of the type parameters that need to be inferred. That is the inferred list of type arguments.
Summary
This completes the proposal (but see the extensions below).
Claim: This set of rules is substantially equivalent to the current spec using function type inference and constraint type inference. It puts both forms of inference into a single framework.
The advantage of doing this is that we can extend type inference by tweaking the framework, rather than by adding new kinds of type inference. Specifically, we can extend how entries are added to the inference map, and we can extend how type unification works.
Examples
Function argument type inference 1
Example from original proposal.
[]int
and[]P
P
=>int
Inference succeeds with
P
receiving the type argumentint
.Function argument type inference 2
Example from original proposal.
[]int
and[]P1
P1
=>int
func(int) string
andfunc(P1) P2
P1
=>int
int
andint
; nothing happensP2
=>string
Inference succeeds with
P1
asint
andP2
asstring
.Untyped constants
Example from original proposal.
1
and2
correspond toP
P
=>int
, twiceInference succeeds with
P
asint
.P
=>int64
1
corresponds toP
, which already has a type, so doesn't do anythingInference succeeds with
P
asint64
.1
and2.5
correspond toP
P
=>int
(default type of1
)P
=>float64
(default type of 2.5`)int
andfloat64
failsInference fails.
Constraint type inference 1
Example from original proposal.
P1
=>MySlice
P1
=>~[]P2
(core type rule)MySlice
and~[]P2
MySlice
, which is[]int
[]int
and~[]P2
P2
=>int
MySlice
, soP1
mapping is unchangedInference succeeds with
P1
asMySlice
andP2
asint
.Constraint type inference 2
Example from original proposal.
P2
=>Settable
(partial instantiation rule)P3
=>*P2
(core type rule)Resolve
P3
mapping usingP2
mapping:P3
=>*Settable
Inference succeeds with
P2
asSettable
andP3
as*Settable
.Argument ordering
Example from #43056.
For
f1(i, j)
:T
=>F
T
=>func(F)
F
andfunc(F)
F
so mapping is unchangedT
=>~func(T)
(core type rule)F
and~func(T)
F
func(F)
and~func(T)
T
=>F
is already in mappingF
so mapping is unchangedInference succeeds with
T
asF
.For
f1(j, i)
the same happens. The mappings may be done in a different order but the unification results are the same.Interleaved inference
In the current inference algorithm, not considering this proposal, function argument type inference and constraint type inference are separate passes. Issue #51139 considers interleaving those passes. With this proposal, that interleaving happens automatically.
Current type inference fails for this code, because function argument type inference resolves all the types, but they aren't the same.
With the algorithm in this proposal:
P1
=>[]MyPtr
P2
=>*int
P1
=>~[]P2
(core type rule)~[]P2
and[]MyPtr
P2
=>MyPtr
~[]P2
and[]MyPtr
is[]MyPtr
soP1
mapping is unchanged*int
andMyPtr
MyPtr
, so changeP2
mapping toMyPtr
Inference succeeds with
P1
as[]MyPtr
andP2
asMyPtr
.Extensions
As mentioned above, we can add new kinds of type inference by adding new rules for adding mappings and for type unification.
In doing this the main guideline is that type inference must never be surprising. Whenever type inference succeeds, it must produce the type arguments that anybody would expect.
Less critical, but still important, is error reporting when type inference fails. There are two kinds of failures: a type unification failure, and a failure to infer a type argument for some type parameter. For the latter it should suffice to simply report that no type could be inferred. For the former more care is needed. Simply reporting a type unification failure may involve types that are far removed from the types that the user sees. It may be necessary to report a series of steps to the failed unification. Or it may be better to simply say that a conflict was found.
Here is a list of possible extensions. It's not clear at present which should be adopted, if any.
Inferring based on interfaces
Example from #41176. This would also address #52397 and probably #56294 and #57192.
Today this fails to infer the type argument for
F
. We can make it succeed by adding a new case for type unification: we permit unifying an interface type and a non-interface type.When we try to unify an interface type
IF
with a non-interface typeT
, we look for the methods ofIF
onT
. If they are not found, type unification fails. If they are found, we unify the types of all the methods, ignoring theT
receiver.Given that rule and the example above, inference proceeds as follows:
I[P2]
andS
I[P2]
is an interface with a methodM() P2
S
has a methodM() byte
P2
=>byte
Inference succeeds with
P2
asbyte
.Inferring based on assignment context
Example from #49297. This approach would also address #50285, and (I think) #47868 and #53138.
In both case 1 and case 2 a call to the function
zero
occurs where the value is being assigned to a variable of known type. Today, this fails to infer the type argument.We can make it succeed by adding a new rule for adding entries to the inference map: when a function call is used such that the result(s) of the function are assigned to variables of known types (including calling a function of known type, or returning a function call from a function of known type), we unify the result type and the type known from context.
Given that rule, and case 1 above, inference of the call to
zero
proceeds as follows:int
(F1
argument type) andT
(zero
result type)T
=>int
Inference succeeds with
T
asint
.Case 2 also succeeds in the same way.
Type inference from caller type parameters
Example from #49575.
The issue here is inferring the type arguments for
F
in the function call insideG
. Currently we can't infer them.We can make this work by adding a new rule for adding entries to the inference map. If we see a function call inside a generic function H, then look at the type parameters for H: if we see a case where the constraint of the type parameter has a core type, we add a mapping P => D where D is the core type of the constraint C. That is, we apply the core type rule not just to the type parameters of the function being called, but also to the type parameters of the function making the call.
FPT
=>GPT
FPT
=>*FT
(original core type rule)GPT
and*FT
GPT
=>*FT
GPT
=>*GT
(new core type rule)*FT
and*GT
FT
=>GT
Inference succeeds with
FT
asGT
andFPT
asGPT
.Inferring using assignment context type parameters
This is a further extension to using the assignment context, in which we also add a new rule for initializing the typemap set: if we are calling a generic function, we apply the core type rule for that function's type parameters.
Example from #52272
We are trying to infer the type argument for
T3
for the call toOption1
.Option[T2]
(argument type ofNewA
) andOption[T3]
(result type ofOption1
)T2
=>T3
T2
=>A
(new core type rule for called functionNewA
)T3
andA
T3
=>A
Inference succeeds with
T3
asA
.Unhandled cases
I haven't worked out a way for this approach to address #56975. The problem there is that there is no core type. The answer might be to permit unification with a type set to succeed if the type is in the type set, with the unification result being the type.
The text was updated successfully, but these errors were encountered: