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

Implement GodotConvert for Vec<T>, [T; N] and &[T] #795

Merged
merged 2 commits into from
Aug 12, 2024
Merged

Implement GodotConvert for Vec<T>, [T; N] and &[T] #795

merged 2 commits into from
Aug 12, 2024

Conversation

Houtamelo
Copy link
Contributor

Rationale:
In multithreaded contexts, I've come across a use case where it was useful to express that a multithreaded operation would return a type that implements ToGodot.

This is one of the cases, in my crate gdext_coroutines:

fn async_task<R>(
	&self,
	f: impl std::future::Future<Output = R> + Send + 'static,
) -> CoroutineBuilder<R>
	where
		R: 'static + ToGodot + Send;

Rust vectors and arrays fit all bounds of R except ToGodot, even if the element type implements ToGodot.

The workaround is to use wrapper types and manually implement GodotConvert for them, but I believe this blanket implementation is simple enough to be worth improving the user experience.

@Bromeon Bromeon added feature Adds functionality to the library c: core Core components labels Jul 12, 2024
@GodotRust
Copy link

API docs are being generated and will be shortly available at: https://godot-rust.github.io/docs/gdext/pr-795

Copy link
Member

@Bromeon Bromeon left a comment

Choose a reason for hiding this comment

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

Thank you! 👍

One thing to note is that we make the Godot equivalent of Vec<T> officially Array<T>, even though PackedTArray might be closer in some cases (same memory layout). But I think that's OK given that Array is more versatile; people could still explicitly convert to packed arrays.

Could you add integration tests for the conversions, e.g. in builtin_tests/convert_test.rs?

Copy link
Contributor

@bend-n bend-n left a comment

Choose a reason for hiding this comment

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

Array => [T; N] should be done without a Vec allocation

godot-core/src/meta/godot_convert/impls.rs Outdated Show resolved Hide resolved
Comment on lines 359 to 369
fn try_from_godot(via: Self::Via) -> Result<Self, ConvertError> {
let vec = via.iter_shared().collect::<Vec<T>>();

Self::try_from(vec)
.map_err(|vec| {
let message = format!(
"Array length mismatch, expected: `{}`, got: `{}`", LEN, vec.len()
);

ConvertError::with_kind_value(ErrorKind::Custom(Some(message.into())), vec)
})
Copy link
Contributor

Choose a reason for hiding this comment

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

This can, and ought, to be done without a collection.

Suggested change
fn try_from_godot(via: Self::Via) -> Result<Self, ConvertError> {
let vec = via.iter_shared().collect::<Vec<T>>();
Self::try_from(vec)
.map_err(|vec| {
let message = format!(
"Array length mismatch, expected: `{}`, got: `{}`", LEN, vec.len()
);
ConvertError::with_kind_value(ErrorKind::Custom(Some(message.into())), vec)
})
fn try_from_godot(via: Self::Via) -> Result<Self, ConvertError> {
use std::mem::{ManuallyDrop, MaybeUninit};
/// [`MaybeUninit::assume_init_array`] polyfill.
///
/// # Safety
///
/// It is up to the caller to guarantee that all elements of the array are
/// in an initialized state.
const unsafe fn aai<T, const N: usize>(array: [MaybeUninit<T>; N]) -> [T; N] {
/// [`core::intrinsics::transmute_unchecked`] polyfill.
/// # Safety
///
/// Same as [`core::mem::transmute()`].
const unsafe fn transmute_unchecked<T, U>(value: T) -> U {
// Create union type that can store a `T` or a `U`.
// We can then use this to convert between them.
//
// The repr(C) layout forces no offset between `t` and `u` as talked about here
// https://rust-lang.github.io/unsafe-code-guidelines/layout/unions.html#c-compatible-layout-repr-c
#[repr(C)]
union Transmute<T, U> {
t: ManuallyDrop<T>,
u: ManuallyDrop<U>,
}
// Create the union in the `T` state.
let value = Transmute {
t: ManuallyDrop::new(value),
};
// Read from the union in the `U` state.
// SAFETY: This is safe because the caller has promised that `T` can be transmuted to `U`.
// The following reference link talks about repr(C) unions being used this way.
// https://doc.rust-lang.org/reference/items/unions.html#reading-and-writing-union-fields
unsafe { ManuallyDrop::into_inner(value.u) }
}
// SAFETY: the caller must guarantee that `self` is initialized.
unsafe { transmute_unchecked(array) }
}
// Make sure the length of the array matches what its copied from.
if via.len() != LEN {
let message = format!(
"Array length mismatch, expected: `{}`, got: `{}`",
LEN,
via.len()
);
return Err(ConvertError::with_kind_value(
ErrorKind::Custom(Some(message.into())),
via,
));
}
// Create an uninit array for the output.
let mut out = [const { MaybeUninit::uninit() }; LEN];
// Copy each element from `via` to the returned value.
for (item, copy) in via.iter_shared().by_ref().zip(&mut out) {
copy.write(item);
}
// SAFETY: all items must have been written to, at this point.
// if a panic has occurred, which I dont think it can, the items in `out` are leaked.
Ok(unsafe { aai(out) })

Copy link
Contributor

Choose a reason for hiding this comment

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

If you prefer not to write out all that unsafe you can use my car crate and just do car::try_from_array_option(|_| iter.next()).map_err(...)

Copy link
Member

Choose a reason for hiding this comment

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

@bend-n Thanks for this implementation! Since this is quite involved and may require its own review, I'd rather not push the responsibility on @Houtamelo for this change and instead suggest you later open a separate issue/PR where we can discuss this.

If people agree that the extra unsafety is worth it, it probably makes sense to extract the polyfills to global functions, e.g. in godot-ffi/src/toolbox.rs. So they could be added independently of GodotConvert and also get their own unit tests.

@Bromeon
Copy link
Member

Bromeon commented Jul 21, 2024

(Rebased onto latest master, just to make sure everything's up-to-date)

@Bromeon
Copy link
Member

Bromeon commented Jul 28, 2024

Seems like the last change broke CI, you should probably revert the suggestion you applied from GitHub web UI...

@Houtamelo
Copy link
Contributor Author

Seems like the last change broke CI, you should probably revert the suggestion you applied from GitHub web UI...

Speaking of which, would you like me to finish this PR? Or wait until the OutArray implementation is done?

@Bromeon
Copy link
Member

Bromeon commented Aug 2, 2024

I'm not sure where the OutArray proposal is heading, and I haven't received much feedback, so I think we can move ahead here.

@Houtamelo
Copy link
Contributor Author

I'm not sure where the OutArray proposal is heading, and I haven't received much feedback, so I think we can move ahead here.

Alright then, I'll try to have this done by the weekend.
I think moving forward makes sense, if anything, we can just make the necessary edits to this when the OutArray impl is done.

@Bromeon
Copy link
Member

Bromeon commented Aug 10, 2024

Any update on this? (No rush on my side, just thought I'd check back since you mentioned last weekend 🙂 )

@Houtamelo
Copy link
Contributor Author

Houtamelo commented Aug 11, 2024

Any update on this? (No rush on my side, just thought I'd check back since you mentioned last weekend 🙂 )

I resumed it today, was almost done until I ran into #851

For now, I've commented the tests that fail due to those issues, I've also cleaned up the commit and ensured check.sh runs successfully.

Also, I edited the implementation of Slice::from_godot which now uses unsafe code but slightly different from the suggestion of @bend-n

@Houtamelo Houtamelo marked this pull request as ready for review August 11, 2024 04:07
Copy link
Contributor

@bend-n bend-n left a comment

Choose a reason for hiding this comment

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

Not a fan of this ultimately pointless unsafe, the options could just be unwrapped normally, as theres no uninit memory happening here, and the whole thing would be safe, and it would be fine, only using a few more bytes than the unsafe solution.

// SAFETY: We checked that the length of via matches `LEN`, therefore idx would only be out of bounds if
// the array is modified during iteration, which should never be done and if done is caught in debug mode
// by the assertion above.
unsafe {
Copy link
Contributor

Choose a reason for hiding this comment

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

This unsafe is pointless-- the you can simply use iterators, and do via.iter_shared().zip(&mut option_array), and just *out = element it like that, or put an assert!(LEN == via_len) at the top and index both of them?

let array = option_array.map(|some|
// SAFETY: We checked that the length of via matches `LEN`, therefore all options are some.
// This could be done with MaybeUninit, but that may cause leaks if a panic occurs in one of the FFI calls
// in `iter_shared()`
Copy link
Contributor

Choose a reason for hiding this comment

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

If you dont want it to leak, you can add a panic guard to properly drop the initialized elements, however if iter_shared() panics isnt that a bigger problem?
Remember, leaking is completely fine.

Comment on lines 377 to 380
debug_assert!(
idx < LEN,
"Elements were added to Array during `iter_shared()`, this is not allowed."
);
Copy link
Contributor

@bend-n bend-n Aug 11, 2024

Choose a reason for hiding this comment

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

This is possible? Isnt it already UB if this happens? Im not sure how iter_shared() works, but this is rather dubious.

If it is possible, it should be a real assert!, and not a debug_assert!, as this is an integral safety condition with that get_unchecked.

Copy link
Member

Choose a reason for hiding this comment

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

If we add any such checks, they should happen inside Iter::next() (for arrays and dictionaries). Because otherwise, each call site has to repeat them, and we'll end up with some validated and some not.

Modify-while-iterate should be safe because it uses indexes internally, and these are bounds-checked. So it only causes logic errors, not memory unsafety, and thus debug_assert! (inside iter_shared() implementation) is fine.

Copy link
Contributor

@bend-n bend-n Aug 11, 2024

Choose a reason for hiding this comment

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

Modify-while-iterate is fine, but if it changes the length of the array, its a problem, and thats what the debug_assert is checking, and if idx >= LEN then get_unchecked_mut is UB.

Copy link
Member

@Bromeon Bromeon left a comment

Choose a reason for hiding this comment

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

Thanks!

The "check if iter_shared works" assertions are well-intended but if we do those, they should be inside the iteration implementations (which have access to the array/dictionary) and not on each call-site. This could be done in a separate PR -- we can focus on this one first.

Comment on lines 311 to 314
.map(|v| v.to_variant())
.unwrap_or_else(|_| {
panic!("to_variant(): u64 value {} is not representable inside Variant, which can only store i64 integers", self)
})
Copy link
Member

Choose a reason for hiding this comment

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

Please change back to spaces 🙂

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Please change back to spaces 🙂

Weird, I just ran cargo fmt, will revert

Comment on lines 377 to 380
debug_assert!(
idx < LEN,
"Elements were added to Array during `iter_shared()`, this is not allowed."
);
Copy link
Member

Choose a reason for hiding this comment

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

If we add any such checks, they should happen inside Iter::next() (for arrays and dictionaries). Because otherwise, each call site has to repeat them, and we'll end up with some validated and some not.

Modify-while-iterate should be safe because it uses indexes internally, and these are bounds-checked. So it only causes logic errors, not memory unsafety, and thus debug_assert! (inside iter_shared() implementation) is fine.

Comment on lines 237 to 239
// Ensure valid conversions from Array to Rust collection types work
#[itest]
fn array_to_rust_valid() {
Copy link
Member

Choose a reason for hiding this comment

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

The different collection types (Vec, array) have independent implementations and should thus have separate #[itest]. It's fine to have multiple element types in one though (f64, GString etc.).

We are likely going to remove support for non-i64 integers and non-f64 floats as element types due to #805 -- so could you maybe use i64, GString and e.g. Vector2 or so?

Also nitpick, comments should generally stop with a period 🙂

Comment on lines 252 to 255
let valid_array_type_2 = godot::builtin::array! {
2_f64,
-64_f64,
};
Copy link
Member

Choose a reason for hiding this comment

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

You can import godot::builtin::array!.

@Houtamelo
Copy link
Contributor Author

@Bromeon Should be all good now

Copy link
Member

@Bromeon Bromeon left a comment

Choose a reason for hiding this comment

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

There are now 500 LoC of tests, which is quite a lot for basically 2 conversions. I think it's good to test different element types, but there's a lot of duplication. Could you maybe try to extract some into generic helper functions?

There is some prior art in roundtrip:

pub fn roundtrip<T>(value: T)
where
T: FromGodot + ToGodot + PartialEq + Debug,
{
// TODO test other roundtrip (first FromGodot, then ToGodot)
// Some values can be represented in Variant, but not in T (e.g. Variant(0i64) -> Option<InstanceId> -> Variant is lossy)
let variant = value.to_variant();
let back = T::try_from_variant(&variant).unwrap();
assert_eq!(value, back);
}

Or also several helper functions from here downward...

godot-core/src/meta/godot_convert/impls.rs Outdated Show resolved Hide resolved
itest/rust/src/builtin_tests/convert_test.rs Outdated Show resolved Hide resolved
@Houtamelo
Copy link
Contributor Author

There are now 500 LoC of tests, which is quite a lot for basically 2 conversions. I think it's good to test different element types, but there's a lot of duplication. Could you maybe try to extract some into generic helper functions?

There is some prior art in roundtrip:

pub fn roundtrip<T>(value: T)
where
T: FromGodot + ToGodot + PartialEq + Debug,
{
// TODO test other roundtrip (first FromGodot, then ToGodot)
// Some values can be represented in Variant, but not in T (e.g. Variant(0i64) -> Option<InstanceId> -> Variant is lossy)
let variant = value.to_variant();
let back = T::try_from_variant(&variant).unwrap();
assert_eq!(value, back);
}

Or also several helper functions from here downward...

I could but I don't know when I'll have time to work on this again

@Bromeon Bromeon linked an issue Aug 11, 2024 that may be closed by this pull request
Co-authored-by: Jan Haller <bromeon@gmail.com>
@Bromeon
Copy link
Member

Bromeon commented Aug 12, 2024

Took me too long to refactor all the tests, so I rewrote them. I didn't test all the details like error message, but the core functionality (conversion) is covered, and I find it quite maintainable now.

Thanks for implementing this feature! 🙂

@Bromeon Bromeon added this pull request to the merge queue Aug 12, 2024
github-merge-queue bot pushed a commit that referenced this pull request Aug 12, 2024
Implement GodotConvert for Vec<T>, [T] and &[T], where T: ArrayElement.
@github-merge-queue github-merge-queue bot removed this pull request from the merge queue due to failed status checks Aug 12, 2024
@Bromeon Bromeon enabled auto-merge August 12, 2024 21:00
@Bromeon Bromeon changed the title Implement GodotConvert for Vec<T>, [T] and &[T], where T: ArrayElement. Implement GodotConvert for Vec<T>, [T; N] and &[T] Aug 12, 2024
@Bromeon Bromeon added this pull request to the merge queue Aug 12, 2024
Merged via the queue into godot-rust:master with commit 6bcaa3c Aug 12, 2024
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
c: core Core components feature Adds functionality to the library
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Implement ToGodot/FromGodot for Vec<T>
4 participants