Refactor: use mbgl::style::conversion system to convert MGLStyleFunction to mbgl core types#8026
Conversation
|
@anandthakker, thanks for your PR! By analyzing this pull request, we identified @boundsj, @1ec5 and @jfirebaugh to be potential reviewers. |
|
Oh, another note: my intent was to keep this PR/refactor scoped to handling StyleFunction types (because those are the ones currently presenting difficulties / blocking my symbol DDS work). But once it's working, propagating this approach to the other MGL => mbgl conversions should be pretty much as simple as implementing |
| template <class Fn> | ||
| optional<Error> eachMember(const MGLConversionValue&, Fn&&) { | ||
| // TODO | ||
| // mbgl::Log::Warning(mbgl::Event::Android, "eachMember not implemented"); |
There was a problem hiding this comment.
(note to self) Here, and in some other places, need to raise/log an error in whatever way is "right" for this SDK/project.
1ec5
left a comment
There was a problem hiding this comment.
This is a good idea for avoiding repetition and modularizing the code. The multistep process is a clever way to work around the fact that C++ method overloading is unable to account for Objective-C class inheritance.
Most of the feedback below pertains to Objective-C concepts. I encourage you to read the linked articles at some point, but a general introduction to the language would help to explain some of the quirkiness you see in any Objective-C++ codebase.
| // | ||
| // Created by Anand Thakker on 2/10/17. | ||
| // Copyright © 2017 Mapbox. All rights reserved. | ||
| // |
There was a problem hiding this comment.
In this repository, we typically remove the comment block that Xcode adds by default. Authorship can be determined from commit history and copyright information is explained in excruciating detail in LICENSE.md.
| // | ||
|
|
||
| #ifndef MGLConversionValue_h | ||
| #define MGLConversionValue_h |
There was a problem hiding this comment.
Neither guard defines nor #pragma once is needed in Objective-C headers, because #import is smart enough to avoid including twice.
|
|
||
| class MGLConversionValue { | ||
| public: | ||
| Value(NSObject * _value) : value(_value) {} |
There was a problem hiding this comment.
Nit: the predominant Objective-C style leaves no space between * and the identifier.
| }; | ||
|
|
||
| private: | ||
| NSObject * value; |
There was a problem hiding this comment.
Use id rather than NSObject * when you don’t want to constrain the variable’s class at all. The compiler won’t bother performing type checking on id. Moreover, unlike void *, id must be an Objective-C object. As a result, as long as you use [bracket syntax] instead of dot.syntax, you can pass any message you like to the id without casting.
(Technically, id also allows the object to be an NSProxy; however, proxies are quite rare in practice.)
There was a problem hiding this comment.
It seems like we do want to constrain the object's class here, though, since we're making assumptions about value being an instance of the NSObject class hierarchy -- no?
There was a problem hiding this comment.
For all practical purposes, NSObject and NSProxy are the only two root classes in Objective-C. If you don't care about the difference between NSObject and NSProxy – you may never encounter any reason to care about this distinction – then id is tantamount to NSObject * except with the convenience of not having to cast to a more specific type.
| Value(NSObject * _value) : value(_value) {} | ||
|
|
||
| bool isNull() const { | ||
| return value; |
There was a problem hiding this comment.
This seems to be returning true if the value is non-null. Moreover, in some existing code that converts from Foundation types to C++ types, we also treat NSNull as a null value.
| /* End PBXCopyFilesBuildPhase section */ | ||
|
|
||
| /* Begin PBXFileReference section */ | ||
| 17C4564F1E4E19BB0029C9B1 /* darwin_conversion.hpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.h; path = darwin_conversion.hpp; sourceTree = "<group>"; }; |
There was a problem hiding this comment.
Remember to add this file to macos.xcodeproj as well, since all the runtime styling code is shared between both platforms.
| return value.getLength();; | ||
| } | ||
|
|
||
| inline mbgl::android::Value arrayMember(const MGLConversionValue& value, std::size_t i) { |
There was a problem hiding this comment.
Heh, actually it should have been MGLConversionValue.
| }; | ||
|
|
||
| Value get(const char* key) const { | ||
| NSDictionary *dict = (NSDictionary *)value; |
There was a problem hiding this comment.
Is it guaranteed that this method will only be called if isObject() returns true? Even if so, it might be worth asserting that we’re dealing with an NSDictionary.
There was a problem hiding this comment.
It's guaranteed in the spec, but agreed on asserting anyway.
| return { value.toString() }; | ||
| } else if (value.isNumber()) { | ||
| // Need to cast to a double here as the float is otherwise considered a bool... | ||
| return { (double) value.toNumber() }; |
There was a problem hiding this comment.
In C++ and Objective-C++, the static_cast syntax allows the compiler to warn you when you try to cast to an unrelated type. A C-style cast, by contrast, allows you to cast to any type with abandon.
| return { value.toString() }; | ||
| } else if (value.isNumber()) { | ||
| // Need to cast to a double here as the float is otherwise considered a bool... | ||
| return { (double) value.toNumber() }; |
There was a problem hiding this comment.
Could MGLConversionValue::toNumber() return a double in the first place, avoiding the need for this cast? Converting float to double often leaves unwanted artifacts at the smallest decimal places.
|
@1ec5 Thank you for this extremely helpful review-slash-Objective-C-lesson! |
f679f55 to
204ebb7
Compare
| @@ -0,0 +1,68 @@ | |||
| #include <string> | |||
|
|
|||
| class MGLConversionValue { | |||
There was a problem hiding this comment.
What's the purpose of MGLConversionValue in this PR, as opposed to inlining each of its methods in the corresponding method in darwin_conversion.hpp? E.g.:
inline bool isUndefined(NSObject *value) {
return !value || value == [NSNull null];
}There was a problem hiding this comment.
@jfirebaugh hah -- yeah, this is exactly the question I started asking myself this morning: #8026 (comment). I started out with this wrapper because I didn't know at the time that a fully/cleanly NSObject-based hierarchy would be possible. If you agree that the wrapper's unnecessary, I'd love to remove it.
There was a problem hiding this comment.
Good point – we aren't actually using method overloading here, so the fact that both bool and float are encapsulated in NSNumber just means the implementation of isBool() and isNumber() would involve an ||.
There was a problem hiding this comment.
So this file should be removed now, right?
|
@1ec5 @jfirebaugh okay, I think this should now be ready for a (re-)review. Two outstanding issues:
|
|
|
|
Oh, I see, the issue isn't the stringification, it's that the conversion back goes through |
ARC is unable to reason about references like |
Ah. Yeah, the *& did seem weird and unnecessary even when I was typing it. But, alas, changing the types of the function parameters to the more sensical So it seems like trying to make an @jfirebaugh @1ec5 does this mean we basically need to stick with having a wrapper class? Or is there another way out? |
|
Added an accuracy threshold to the color equality checks per #8026 (comment). @1ec5 as those were my first lines of Swift, I would imagine that I've violated any number of conventions/idioms, heh. |
It’s already a pointer, so normally you’d the absence of a value with |
Yep, it expects |
|
|
||
|
|
||
| #if os(iOS) || os(watchOS) || os(tvOS) | ||
| typealias ColorType = UIColor |
There was a problem hiding this comment.
In Objective-C code, we’ve been using MGLColor as the name of the platform-agnostic color type.
| #endif | ||
|
|
||
| extension MGLStyleValueTests { | ||
| func assertColorsEqualWithAccuracy(_ actual: ColorType, _ expected: ColorType, accuracy: Float = 0.1) { |
There was a problem hiding this comment.
Just an observation, not a request for change: Swift lets you overload methods like in C++. So you could overload XCTAssertEqualWithAccuracy(_:_:accuracy:) with two MGLColor arguments. Copying the existing XCTAssertEqualWithAccuracy(_:_:accuracy:) signature, you’d wind up with:
func XCTAssertEqualWithAccuracy<T : FloatingPoint>(_ color1: MGLColor, _ color2: MGLColor, accuracy: T, _ message: @autoclosure () -> String = "", file: StaticString = #file, line: UInt = #line)With this fancy signature, you could pass file and line into the calls to the normal XCTAssertEqualWithAccuracy(_:_:accuracy:) and get a failure message that refers to the original line number.
|
|
||
|
|
||
| #if os(iOS) || os(watchOS) || os(tvOS) | ||
| typealias ColorType = UIColor |
There was a problem hiding this comment.
With this typealias in place, it might be possible to unconditionalize the giant #if block in testFunctionsWithDataDrivenProperties().
| #endif | ||
|
|
||
| extension MGLStyleValueTests { | ||
| func assertColorsEqualWithAccuracy(_ actual: ColorType, _ expected: ColorType, accuracy: Float = 0.1) { |
There was a problem hiding this comment.
Is 0.1 all the accuracy we can guarantee?
There was a problem hiding this comment.
Oh, whoops, I meant for that to be 0.01, although I guess 0.005 is maybe a better, as a roundish number close to 1/255.
| if let actualConstant = actual as? MGLStyleConstantValue<ColorType> { | ||
| XCTAssertTrue(expected is MGLStyleConstantValue<ColorType>) | ||
| assertColorsEqualWithAccuracy(actualConstant.rawValue, (expected as! MGLStyleConstantValue<ColorType>).rawValue) | ||
| return |
There was a problem hiding this comment.
It looks like no code would get executed beyond this if statement, so no return statement is needed.
| // TODO: assert default values are equal | ||
| if actualFunction.stops == nil { | ||
| XCTAssertNil(expectedFunction.stops) | ||
| return |
There was a problem hiding this comment.
Use a guard … else statement for an early return. (Accentuate the positive!) Better yet, use a guard let … else statement for an early return conditioned on the existence of a variable you want to make use of below.
There was a problem hiding this comment.
Ooh, guard let is so nice!
| @@ -0,0 +1,68 @@ | |||
| #include <string> | |||
There was a problem hiding this comment.
Also import any Objective-C headers you need, namely the Foundation/Foundation.h umbrella header. You’re getting away with omitting them right now because you happen to be importing this header after other headers in other files.
| return [array count]; | ||
| }; | ||
|
|
||
| MGLConversionValue get(const std::size_t index ) const { |
There was a problem hiding this comment.
size_t isn’t explicitly defined to be the same size as NSUInteger (the type used to subscript NSArray below), even though I think that happens to be the case. To avoid even the potential of an out-of-bounds array access, you could assert that index is at most NSUIntegerMax.
| #import "NSValue+MGLStyleAttributeAdditions.h" | ||
| #import "MGLTypes.h" | ||
|
|
||
| #include "darwin_conversion.hpp" |
There was a problem hiding this comment.
The convention for our predominantly Objective-C targets is to use .h for Objective-C++ headers. We also use MGLCamelCase for these headers.
| @@ -0,0 +1,132 @@ | |||
| #pragma once | |||
There was a problem hiding this comment.
As long as this header is #imported, no #pragma once guard is needed.
| #include <mbgl/util/logging.hpp> | ||
| #include <mbgl/style/conversion.hpp> | ||
| #include <mbgl/util/feature.hpp> | ||
| #include <mbgl/util/optional.hpp> |
There was a problem hiding this comment.
Also import the Foundation/Foundation.h umbrella header.
| } | ||
|
|
||
| NSObject* operator*() { | ||
| assert(this); |
There was a problem hiding this comment.
Use NSCAssert() instead of assert() for files that are part of the Objective-C targets. NSCAssert() provides better diagnostics via NSException and works with Xcode’s built-in Objective-C exception breakpoint.
| // due to things like `optional(const value_type& __v)` | ||
| class OptionalNSObjectValue { | ||
| public: | ||
| OptionalNSObjectValue(NSObject *_value) : value(_value) {} |
There was a problem hiding this comment.
Annotate value’s type as _Nullable and surround the header’s contents in NS_ASSUME_NONNULL_BEGIN and NS_ASSUME_NONNULL_END to enable better compiler/analyzer diagnostics.
| return value; | ||
| } | ||
|
|
||
| NSObject* operator*() { |
There was a problem hiding this comment.
Nit: move the space to before the *.
|
|
||
| inline std::size_t arrayLength(const NSObject* value) { | ||
| NSCAssert([value isKindOfClass:[NSArray class]], @"Value must be an NSArray for getLength()."); | ||
| NSArray * array = (NSArray *)value; |
There was a problem hiding this comment.
I’d still encourage you to explore the use of id instead of NSObject *. Saying “NSObject” here tells the compiler (and the code reviewer) that the function wants an NSObject specifically, not some subclass, which is why you end up having to cast all the time.
| return { *toString(value) }; | ||
| } else if (_isNumber(value)) { | ||
| // Need to cast to a double here as the float is otherwise considered a bool... | ||
| return { (double) *toNumber(value) }; |
There was a problem hiding this comment.
Use static_cast instead of a C-style cast.
| class OptionalNSObjectValue { | ||
| public: | ||
| OptionalNSObjectValue(NSObject *_value) : value(_value) {} | ||
| OptionalNSObjectValue() {} |
There was a problem hiding this comment.
Omit this constructor...
| if (value && value != [NSNull null]) { | ||
| return { member }; | ||
| } else { | ||
| return {}; |
There was a problem hiding this comment.
...and return { nullptr }; here.
| } | ||
|
|
||
| template <class Fn> | ||
| optional<Error> eachMember(const NSObject*, Fn&&) { |
There was a problem hiding this comment.
Don't define this method at all. Then you'll get a compiler error if something tries to use it.
1ec5
left a comment
There was a problem hiding this comment.
This is looking pretty good, especially on the C++ side. assertColorValuesEqual(_:_:) could use some more massaging, but you’re almost over the finish line.
|
|
||
| // A minimal wrapper class conforming to the requirements for `objectMember(v, name)` (see mbgl/style/conversion.hpp) | ||
| // This is necessary because using `NSObject*` as the value type in `optional<NSObject*>` causes problems for the ARC, | ||
| // due to things like `optional(const value_type& __v)` |
There was a problem hiding this comment.
This looks like a documentation comment. You can use the /** … */ syntax for a multiline documentation comment or the /// … syntax for a single-line documentation comment.
| NSCAssert([value isKindOfClass:[NSArray class]], @"Value must be an NSArray for getLength()."); | ||
| NSArray *array = value; | ||
| auto length = [array count]; | ||
| NSCAssert(length < std::numeric_limits<size_t>::max(), @"Array length out of bounds."); |
There was a problem hiding this comment.
To be pedantic, shouldn’t a length equal to the maximum representable length also be allowable? 😉
| inline OptionalNSObjectValue objectMember(const id value, const char *key) { | ||
| NSCAssert([value isKindOfClass:[NSDictionary class]], @"Value must be an NSDictionary for get(string)."); | ||
| NSDictionary *dict = value; | ||
| NSObject *member = dict[@(key)]; |
There was a problem hiding this comment.
Nit: subscript notation in this case is shorthand for a call to -[NSDictionary objectForKey:]. You can avoid creating a separate dict variable by calling -objectForKey: on the id-typed value.
| NSCAssert([value isKindOfClass:[NSArray class]], @"Value must be an NSArray for get(int)."); | ||
| NSCAssert(i < NSUIntegerMax, @"Index must be less than NSUIntegerMax"); | ||
| NSArray *array = value; | ||
| return array[i]; |
There was a problem hiding this comment.
Nit: subscript notation in this case is shorthand for a call to -[NSArray objectAtIndex:]. You can avoid creating a separate array variable by calling -objectAtIndex: on the id-typed value.
There was a problem hiding this comment.
👍 @1ec5 I take it that the purpose of doing this would be for code conciseness, right?
There was a problem hiding this comment.
Yes. It matters more for more complex functions where you’d otherwise potentially have to keep both variables in sync.
|
|
||
| inline optional<std::string> toString(const id value) { | ||
| if (_isString(value)) { | ||
| return std::string([((NSString *)value) UTF8String]); |
There was a problem hiding this comment.
Nit: extra parentheses around value make the line harder to read. Incidentally, you can also write:
std::string((const char *)[value UTF8String])There was a problem hiding this comment.
In the form you suggested, would static_cast be preferable? If not, why not?
| } | ||
|
|
||
| func assertColorValuesEqual(_ actual: MGLStyleValue<MGLColor>, _ expected: MGLStyleValue<MGLColor>) { | ||
| if let actualConstant = actual as? MGLStyleConstantValue<MGLColor> { |
There was a problem hiding this comment.
This is fine, but FYI you can avoid having to come up with a unique name for the coerced value by shadowing the original variable’s name:
if let actual = actual as? MGLStyleConstantValue<MGLColor> …| let expectedStops = expectedFunction.stops! | ||
| XCTAssertEqual(actualStops.keys.count, expectedStops.keys.count) | ||
| let actualKeys = actualStops.keys.sorted(by: { String(describing: $0) < String(describing: $1) }) | ||
| let expectedKeys = expectedStops.keys.sorted(by: { String(describing: $0) < String(describing: $1) }) |
There was a problem hiding this comment.
You can assert that actualStops.keys == expectedStops.keys to check both the count and the existence of identical pairs at the same time.
| } | ||
| let expectedStops = expectedFunction.stops! | ||
| XCTAssertEqual(actualStops.keys.count, expectedStops.keys.count) | ||
| let actualKeys = actualStops.keys.sorted(by: { String(describing: $0) < String(describing: $1) }) |
There was a problem hiding this comment.
Normally, you can call the operator directly instead of writing a new closure:
let actualKeys = actualStops.keys.sorted(by: <)This syntax relies on type inference to choose the right operator. I’m not sure if it’s a problem that the compiler thinks the keys are of type Any rather than Comparable.
The original keys are Strings in some cases and NSNumbers (or perhaps Floats?) in other cases. With NSNumber, you’d probably need .floatValue or at least as Float. With the current code, you’re going to wind up with some weird sorting in the case of NSNumber. For example, "100" < "2". Fortunately, there shouldn’t be a problem as long as both arrays are sorted consistently.
There was a problem hiding this comment.
Yeah -- I originally tried just using < as the sort function, but it seemed to think that the keys were of type Any. I landed at the same conclusion as you: that the weird alphanumeric sort wouldn't matter here... but, alas, I think we should probably figure this key type thing out in more detail -- if the conversion stuff is turning numeric stop values into string values, that's a bug, and comparing stringified values of the keys like we're doing here would occlude it.
There was a problem hiding this comment.
What if?
var actualKeys: [Any]
if let keys = actualStops.keys as? [String] {
actualKeys = keys.sorted(by: <)
} else if let keys = actualStops.keys as? [Float] {
actualKeys = keys.sorted(by: <)
} else {
XCTFail("Stop keys must be either Strings or Floats.")
}| let actualKeys = actualStops.keys.sorted(by: { String(describing: $0) < String(describing: $1) }) | ||
| let expectedKeys = expectedStops.keys.sorted(by: { String(describing: $0) < String(describing: $1) }) | ||
|
|
||
| for (ak, ek) in zip(actualKeys, expectedKeys) { |
There was a problem hiding this comment.
The documentation for Array.values says:
When iterated over, values appear in this collection in the same order as they occur in the dictionary’s key-value pairs.
So it might be possible to say zip(actual.values, expected.values) to avoid the relative inefficiency of subscripting both dictionaries for each key.
There was a problem hiding this comment.
It's not clear to me from that documentation that two different dictionaries' .values would be in a consistent order... indeed, why would they?
| let expectedFunction = expected as! MGLStyleFunction<MGLColor> | ||
| XCTAssertEqual(actualFunction.interpolationBase, expectedFunction.interpolationBase) | ||
| XCTAssertEqual(actualFunction.interpolationMode, expectedFunction.interpolationMode) | ||
| // TODO: assert default values are equal |
There was a problem hiding this comment.
Does this still need to happen?
| template <class T, class V> | ||
| Result<optional<T>> convertDefaultValue(const V& value) { | ||
| auto defaultValueValue = objectMember(value, "defaultValue"); | ||
| auto defaultValueValue = objectMember(value, "default"); |
There was a problem hiding this comment.
Can you investigate why this mistake didn't trigger test failures? Contrary to my earlier statement, we do have tests which I would have expected to fail due to this, added in mapbox/mapbox-gl-js@e328b80.
There was a problem hiding this comment.
👍 yep, will do
There was a problem hiding this comment.
@jfirebaugh The reason it didn't trigger failures is that the tests are marked ignored on native. I'll submit a PR to gl-js to un-ignore them.
|
@1ec5 updated to address the issues you raised, including an attempt to clean up the |
Picks up mapbox/mapbox-gl-js#4212, which was rebased to include mapbox/mapbox-gl-js#4279 and mapbox/mapbox-gl-js#4280
5fd7cf5 to
9a1094b
Compare
Quoting from #7944 (comment) (see the thread there for a bit more context):
The approach I'm going for here is for
MGLStyleValueTransformer::toDataDrivenPropertyValue()to:NSObject * MGLStyleValueTransformer::toRawStyleSpecValue(MGLStyleFunction<T> *value)method to convert anMGLStyleFunctionto an plain NSObject-based hierarchy isomorphic to the style spec JSON for the given style function value.MGLConversionValue, whose job is simply to be a convenience wrapper for thembgl::style::conversionoverloads provided indarwin_conversion.hpp.convert<mbgl::style::DataDrivenPropertyValue<MBGLType>>(value)and it "just works". (More on the conversion system here)cc @1ec5 @boundsj - (note this is still WIP; happy to have early course corrections if you want to peek, but feel free to just ignore for now and I'll ping when it's ready)