focustense / StardewUI

UI/widget library for Stardew modding
MIT License
5 stars 1 forks source link

Duck typing conversions #21

Closed focustense closed 2 weeks ago

focustense commented 1 month ago

A lot of the design in the Framework and StarML is oriented around Pintail and its requirements, but it recently occurred to me that after data binding has occurred, Pintail is entirely irrelevant; that is, view bindings have direct access to the bound context and so Pintail wouldn't help with "proxying" at that point even if the types were interfaces.

So really, it's up to StardewUI to decide whether or not to support this fuzzy type conversion. There's a case to be made that it should. For example, suppose the client defines:

public record Sprite(Texture2D Texture, Rectangle SourceRect, Vector4 FixedEdges);

or even

public class CropData
{
    public Texture2D Texture { get; set; }
    public Rectangle SourceRect { get; set; }
    // other properties
}

These aren't assignment-compatible with Sprite, but their properties are assignment-compatible with all the required properties of Sprite. Having some form of duck typing is really not a far cry from what the Framework is already doing with property assignments and conversions.

A possible specification would go as follows:

  1. Disallow conversions to any primitive destination type (int, float, etc.) since that can never succeed and we don't want to waste the cycles checking. struct types are fine, just not primitives.
  2. For each constructor on the target type, match arguments with property names of the source type. Either case-insensitive comparison or camelCase to PascalCase conversion.
  3. Choose the constructor with the highest count of matched arguments. (We intentionally do this before validation, because I don't like the idea of ambiguous and implicit fallback behavior; either the constructor that one would expect to match does match, or it's an error.)
  4. Validate that each source property is conversion-compatible, either having the exact type required or having a source->dest converter available. This includes optional arguments; it is OK to simply lack a property corresponding to an optional arg, but not OK to have an incompatible property.
  5. For any additional properties on the target type that are (a) public, (b) writable and (c) not already specified in a ctor argument with the same name, do the same mapping and validation from source to destination in the previous step.
  6. Generate and cache the converter one time like we do for binding types everywhere else.

Additionally, if the only matching constructor (or the only constructor at all) is a default parameterless constructor, and there aren't any properties on the source type matching properties on the destination type, then either don't allow the conversion, or log a prominent warning (one time) that the conversion is meaningless and therefore probably wrong.

This might need to handle fields on the source type, too. While I'm not crazy about it due to the performance issues, I expect it's just going to be a thing that comes up. (See rationale in #15).

This specification heavily favors record types, in which ctor args are always the same as property names, and that's intended. It can still be made to work with other classes, though, by using case-insensitive comparisons or case transformations as mentioned earlier.

Anticipated challenges include:

Side note: while the practice should be heavily discouraged, this also solves the problem with devs importing the Core Library as a Shared Project and trying to use those types; since they are in fact the same logical types, they should always be duck-type compatible.

focustense commented 2 weeks ago

Committed a working implementation for this. Don't yet know how well it works, but the existing types give a decent amount of stress testing already with default parameters, nullable value types, etc., so it should cover a wide range of cases.

Probably needs a quick doc update on the type conversions so I won't close this until that's added.

focustense commented 2 weeks ago

Was getting ready to close this, and then read over the notes again and realized that I completely forgot about collections, so this could drag on for quite a while longer, unfortunately.

Still, it's in a pretty good state as most (maybe all?) of the types that would actually benefit from this conversion don't use collections anyway: Sprite, Edges, Bounds, etc.

(Edit: Was clearly suffering from lack of sleep. Collection conversions have nothing to do with duck types; they should operate the same way as Nullable<T> conversions. If the basic "form" of collection - list, dictionary, etc. - is convertible, and the element type is convertible through any means, then the collection itself is convertible.)