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

Remove enumerable runtime checking by default #3293

Merged
merged 25 commits into from
Jul 23, 2020

Conversation

aisamanra
Copy link
Contributor

@aisamanra aisamanra commented Jul 17, 2020

Motivation

This adds an extra parameter to valid? that defaults to false which governs whether it recurses into generic collection types for runtime type-checking. This allows us to avoid it by default (avoiding a big source of slowdown) but still do it if we need to, and opens up the possibility of future features which can "opt-in" to recursive checking for extra safety.

This also adds the deep parameter to assert_type!; methods which rely on "deep" runtime checking can retain their old externally-visible runtime behavior by using something like this:

sig {params(xs: T::Hash[String, T::Array[Integer]]).void}
def foo(xs)
  assert_type!(xs, T::Hash[String, T::Array[Integer]], deep: true)
  # ...
end

A standing question is whether we want to enable this by default for sigs using e.g. a construct like .checked(:deep). Also, should the deep parameter be called something like recursive?

Test plan

Updated tests to include both recursive and non-recursive variants of the same behavior. TODO: test over pay-server.

See included automated tests.

@aisamanra aisamanra force-pushed the gdritter/enumerable-runtime-take-two branch from fa57043 to 611e306 Compare July 20, 2020 19:21
@aisamanra aisamanra marked this pull request as ready for review July 20, 2020 19:21
@aisamanra aisamanra requested a review from a team as a code owner July 20, 2020 19:21
@aisamanra aisamanra requested review from jez and removed request for a team July 20, 2020 19:21
@@ -12,7 +12,7 @@ def name
end

# @override Base
def valid?(obj)
def valid?(obj, deep=false)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why is this positional while the others are keyword? I generally prefer that optional boolean arguments are keyword arguments.

Copy link
Collaborator

Choose a reason for hiding this comment

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

On second thought, seeing how this is used throughout the typed_enumerable code below, it seems really easy to forget to forward through the deep parameter. Can we make this parameter required, and change pay-server?

@@ -13,7 +13,7 @@ def underlying_class
end

# @override Base
def valid?(obj)
def valid?(obj, deep=false)
obj.is_a?(Array) && super
Copy link
Collaborator

@jez jez Jul 20, 2020

Choose a reason for hiding this comment

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

ooc, we could short circuit all of these (typed array, typed hash, etc.) if deep is true, right? Why have you chosen to unconditionally call super?

@jez
Copy link
Collaborator

jez commented Jul 20, 2020

@aisamanra Can you remind me what the motivation is here?

@aisamanra
Copy link
Contributor Author

This now has a recursively_valid? (instead of valid? with an argument) and I've removed the deep: keyword argument in favor of an entirely separate top-level helper method: this will make it trivial to delete if we ever want to do this.

@aisamanra aisamanra requested a review from jez July 20, 2020 22:39
Comment on lines 160 to 161
# statically known and being checked appropriately. If `checked` is true, raises an exception at
# runtime if the value doesn't match the type.
Copy link
Collaborator

Choose a reason for hiding this comment

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

This comment looks out of date?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Comment updated to be correct now. I've also added a bit to the docs, since I had forgotten to do that.

Comment on lines 160 to 161
# in some cases this runtime check can be very expensive, especially
# with large collections of objects.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
# in some cases this runtime check can be very expensive, especially
# with large collections of objects.
# in some cases this runtime check can be very expensive, especially
# with large collections of objects.
#
# This doesn't have any connection to static type refinements at all.
# If you additionally want the type to be cast statically, use `T.let` or `T.cast`.

@@ -13,10 +13,15 @@ def underlying_class
end

# @override Base
def valid?(obj)
def recursively_valid?(obj)
obj.is_a?(Array) && super
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
obj.is_a?(Array) && super
valid?(obj) && super

Copy link
Collaborator

Choose a reason for hiding this comment

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

^ This might be a change you want to make in a few other places too (just for consistency)

when Set
obj.each do |item|
return false unless @type.valid?(item)
return false unless @type.recursively_valid?(item)
Copy link
Collaborator

Choose a reason for hiding this comment

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

I like this a lot better with the recursively_valid? naming / boolean blindness issues.

rbi/sorbet/t.rbi Outdated
@@ -32,6 +32,9 @@ module T
sig {params(value: T.untyped, type: T.untyped, checked: T::Boolean).returns(BasicObject)}
def self.assert_type!(value, type, checked: true); end

sig {params(value: T.untyped, type: T.untyped).returns(BasicObject)}
def self.check_type_recursive!(value, type); end
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do we want to put this in T::Private? Do we intend this to be a public API?


assert_raises(TypeError) do
StructHash.new(the_hash: {'foo' => {}})
end
Copy link
Collaborator

Choose a reason for hiding this comment

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

Was this deleted just because it's identical to below? Is it worth keeping the .new call around and just deleting the assert_raises?


it "does do recursive type-checking of arrays with `check_type_recursive!`" do
assert_raises(TypeError) do
T.check_type_recursive!([1], T::Array[String])
Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah seeing this twice, I'm leaning towards at least putting this in T::Utils or something, and not putting it on T directly, because I think we don't really want to encourage people to depend on this accidentally.

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 think that for now I'd advocate for T::Utils, but it depends on how heavily we think we'll remove this entirely in the future. I think it makes some sense to keep it around, but if we're otherwise pretty sure it'll be removed at some point, then maybe T::Private is the right place for it?

end

true
end
Copy link
Collaborator

Choose a reason for hiding this comment

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

Feel free to push back here. Do you think this method could be improved by implementing it something like

valid?(obj) && <logic for recursively_valid>

?

(motivation: looks like a lot of duplication with valid?)

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 was going to push back for performance concerns (since it'll re-traverse the hash twice, once for valid? and once for recursively_valid?) but on reflection, if you're using recursively_valid?, then you're already throwing perf out the window, so I'll do this refactor.

Copy link
Collaborator

@jez jez left a comment

Choose a reason for hiding this comment

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

Some small suggestions but I love the new overall direction.

Copy link
Contributor

@djudd-stripe djudd-stripe left a comment

Choose a reason for hiding this comment

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

This seems to make the ::Untyped variants of TypedArray, TypedHash etc mostly redundant. Maybe not for this PR, but should we follow up and remove them?

# @override Base
def recursively_valid?(obj)
obj.is_a?(Array) && obj.length == @types.length &&
obj.zip(@types).all? {|item, type| type.recursively_valid?(item)}
Copy link
Contributor

Choose a reason for hiding this comment

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

Might want to rebase after #3302 is merged

@@ -16,6 +16,13 @@ def name
"{#{@types.map {|(k, v)| "#{k}: #{v}"}.join(', ')}}"
end

# @override Base
def recursively_valid?(obj)
Copy link
Contributor

Choose a reason for hiding this comment

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

Ditto, might want to rebase after #3302

@@ -42,6 +42,11 @@ def name
end
end

# @override Base
def recursively_valid?(obj)
Copy link
Contributor

Choose a reason for hiding this comment

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

Seems like the non-enumerable variants of recursively_valid? don't have any test coverage - am I missing something?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You're right—I've gone ahead and updated the types test now to test not only both code paths but ensure that the returned error messages are identical in every test where we'd expect them to be the same.

@aisamanra aisamanra force-pushed the gdritter/enumerable-runtime-take-two branch from af809ab to 456ecd6 Compare July 23, 2020 18:13
@aisamanra aisamanra requested a review from jez July 23, 2020 20:19
@aisamanra
Copy link
Contributor Author

@jez I've made a few changes and so for thoroughness I figured I'd request a re-review. The changes since the last one are

  1. setters and constructors to T::Struct and friends are now type-checked
  2. the tests are now more thorough, and for everything where valid? and recursively_valid? are expected to be the same it'll test both and also test that the two parameters are identical.

@jez
Copy link
Collaborator

jez commented Jul 23, 2020

setters and constructors to T::Struct and friends are now type-checked

I assume this is at the request of kv-client, right? would you mind adding a note above the recursively_valid? calls mentioning something that it was an explicit decision to do that?

it 'fails if an element of the array is the wrong type under deep checking' do
type = T::Array[Integer]
value = [true]
msg = type.error_message_for_obj_recursive(value)
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍

Copy link
Collaborator

@jez jez left a comment

Choose a reason for hiding this comment

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

Thanks!

@@ -15,10 +15,15 @@ def name
end

# @override Base
def valid?(obj)
def recurisvely_valid?(obj)
Copy link
Contributor

Choose a reason for hiding this comment

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

Typo?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oof, yes. …apparently not only do we not test that, we must not use it anywhere in pay-server, either.

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

Successfully merging this pull request may close these issues.

None yet

3 participants