Mapbox-gl-native: Reimplement MGLStyleValue atop NSExpression

Created on 15 Feb 2017  Âˇ  21Comments  Âˇ  Source: mapbox/mapbox-gl-native

MGLStyleValue should be reimplemented as a subclass of or wrapper around NSExpression, and MGLStyleLayer layout and paint properties should accept NSExpressions instead of MGLStyleValues.

Especially once we introduce support for arbitrary arithmetic and conditional expressions in style values (mapbox/mapbox-gl-js#4077 and possibly mapbox/mapbox-gl-js#4079), maintaining a custom vocabulary and type system around these values will become a major burden. Though it’s nice that the current MGLStyleValue system is strongly typed, taking better advantage of type inference in Swift, our API surface area and the learning curve will increase quite a bit as we essentially try to reinvent a statically-typed NSExpression.

NSExpression performs runtime type checking for initialization and static type checking for inspection. For maximum developer comfort, it features a format string mini-language and a series of strongly-typed getters that are applicable to certain kinds of expressions. MGLVectorStyleLayer takes advantage of all these features by representing filters as NSPredicates, which consist of NSExpressions. As with NSPredicate, developers are already familiar with NSExpression and its format string syntax, so there won’t be much of a learning curve at all.

The one thing NSExpression doesn’t provide is the concept of a style function. It does provide a rather awkward extensibility mechanism; we could extend NSExpression to understand functions and provide a more ergonomic, strongly typed API for working with functions. For constant values and expressions, the developer would use NSExpression directly. https://github.com/mapbox/mapbox-gl-native/issues/5970#issuecomment-253087889 has a sketch of the proposed API.

My understanding is that mapbox/mapbox-gl-js#4077 would require a style specification version bump, which would precipitate an SDK major version bump, so we probably don’t need to worry so much about backwards compatibility for this refactoring.

/cc @boundsj @incanus @lucaswoj @jfirebaugh

SEMVER-MAJOR iOS macOS refactor runtime styling

Most helpful comment

I'm a fan of using a combination of # 1 (from https://github.com/mapbox/mapbox-gl-native/pull/10726#issuecomment-353646221) and # 3

For # 1, I agree that we'll want some kind of enemy tests or at least integrated tests which have the potential to alert us to changes in the classes we'd be relying on.

All 21 comments

The one thing NSExpression doesn’t provide is the concept of a style function.

Are you referring to categorical/interval/exponential style functions or the general idea of a function?

In this ticket, I’m referring to category/interval/exponential style functions, of the sort already supported by the style JSON format. We would need to extend the NSExpression system to support that concept. All the other ideas that have been floated, like conditionals and arithmetic, are already supported out of the box. NSExpression could also be extended with arbitrary selectors (aka programmatic callbacks), but that’s outside the scope of this feature proposal.

https://github.com/mapbox/mapbox-gl-js/pull/4715#pullrequestreview-38868643 outlines how an expression syntax proposed for the style JSON file format would translate to NSExpression.

The expression feature is coming along in mapbox/mapbox-gl-js#4777 and mapbox/mapbox-gl-js#4841. This is a good time to start designing the iOS/macOS equivalent so we know where the impedance mismatches will be.

To restate the points made above, the most natural representation for style JSON expressions would be NSExpression. NSExpression has the benefit of being widely known among iOS developers: as the core of the NSPredicate syntax, it’s used for tasks ranging from filtering an array to searching Core Data or Spotlight. It also takes a pragmatic approach, eschewing compile-time type safety in favor of a compact, memorable format string syntax (with a more structured syntax as a little-used alternative).

The downside is that the iOS and macOS SDKs will diverge syntactically from GL JS, even moreso than the Android SDK. However, the alternatives don’t lend themselves to concision or expressiveness in a library written in Objective-C.

To illustrate why we should use NSExpression instead of hewing to the style JSON syntax, consider the following constraints:

  • A JSON-like syntax based on array and dictionary literals would still entail many syntax differences between JSON and Objective-C, such as all the @s. In Swift, all the expressions in mapbox/mapbox-gl-js#4836 would have such complex inferred types that they’d fail to compile or take inordinately long to compile.
  • A fully object-oriented API like the existing MGLStyleValue API doesn’t scale well. Even though MGLStyleValue provides only a tiny fraction of the expressiveness of the proposed expression syntax, it requires a lot of typing and parameterizing, making it onerous to deviate from example code. By comparison, our implementation of style filters as predicates (#5973) largely avoided these problems.
  • The Android SDK’s approach of nested functions for filters is similar to the structured syntax for NSExpression. Unfortunately, in Objective-C, it would still be a lot of typing and parameterizing, because Objective-C lacks namespaces and strenuously avoids abbreviations. A variation based on standalone C functions could be more compact but would feel foreign and unapproachable. A syntax based on C macros could be just as compact as the style JSON format, but there would be severe limits to its expressiveness. (For example, you wouldn’t be able to nest one macro invocation inside another.)

By committing to NSExpression, we’re signing up to do a lot of translation work, and we may need to perform gymnastics at times to reconcile two very different syntaxes with different feature sets. To the extent possible, I think we should prioritize the following goals:

  • Idiomatic: An iOS developer should be able to write basic expressions without consulting example code. Where NSExpression offers a built-in, usable syntax, we should strive to use it instead of inventing our own syntax. This may require some on-the-fly rewriting of expressions.
  • Approachable: Some of the less frequented corners of the NSExpression language can be intimidating, particularly the function syntax. We should offer method-level conveniences that keep developers away from those arcane syntaxes.
  • Correct: The translation between style JSON expressions and NSExpression should be lossless, even if rewriting means that an expression won’t literally round-trip. We’ll need good unit test coverage like what we have for the filter-predicate conversion code.

I wouldn’t say the following are non-goals, but they’re definitely lower priorities:

  • Type-safe: MGLStyleValue suffers from its rigid, compile-time type-safety. It’s a pain to write anything more complex than a constant style value. To the extent the style specification and mbgl allow, we should instead rely on runtime type checking, which enables us to use format strings. In particular, mandatory type annotation would be contrary to both Objective-C (which practices weak typing where convenient) and Swift (where type annotations are often omitted due to type inference).
  • Consistency with style JSON: The constraints above all but prevent us from exposing a syntax that closely hews to the style JSON format. If this becomes a problem, such as for React Native Mapbox GL integration, we could implement some JSON conversion functions as a convenience.

Here are some areas that, at this early stage, already look like they’ll be a bit messy:

  • Feature property access and type coercion: The CAST() function will probably be news to most iOS developers who are otherwise familiar with expressions.
  • Switch statements: We’ll need to rewrite them as nested if/then/else statements.
  • String concatenation: Oddly, NSExpression doesn’t come with a built-in function for concatenation, so you have to use the FUNCTION(####, "stringByAppendingString:", ####) syntax. We can provide a convenience method that wraps this function, but we can’t simplify the format string syntax at all.

/cc @anandthakker

The question came up the other day whether we would consider implementing our own expression system, instead of using NSExpression, if the style JSON expression format ends up dominated by concepts that are difficult to express in NSExpression’s format string syntax. I think the answer is no for the following reasons:

  • NSExpression is the language’s answer for expressions. Just as we wouldn’t implement our own NSString or NSArray replacement, we shouldn’t get into the business of defining our own version of this language primitive.
  • NSExpression comes with a mature parser. (This is incidentally the only part of NSPredicate we use for filters.) It’s a significant amount of work to reliably and safely parse format strings. We can’t simply reuse the NSString format string functionality, because we wouldn’t want (for example) an NSArray or NSSet to be flattened into whatever their -description methods return, which would be incompatible with our expression language.
  • Other than a few basic syntaxes, NSExpression is already at the far end of what the majority of iOS developers would find usable. If style expressions require additional complexity that NSExpression can’t express well, that’s a sign we need to rethink style expressions. Otherwise, we’re dooming this feature to be used only in a few Mapbox-designed styles and never to be used in runtime styling.

Here are some areas that, at this early stage, already look like they’ll be a bit messy

From a cursory glance at #9439, another impedance mismatch will come from the fact that mbgl::style::expression::type::Array expects an explicit element type. This requirement appears to have been introduced to facilitate stronger type checking at the C++ level, whereas there’s less desire for strong type checking at the Objective-C level.

Objective-C supports annotating an NSArray’s element type via lightweight generics, but it’s lightweight in the sense that only the compiler ever sees the generic parameter; it’s inaccessible to us at runtime. Our options would be:

  • Iterate over all the elements of the array to determine the elements’ types. The developer would be able to use the familiar format string syntax, and they can specify or omit the type when creating the NSArray, in keeping with the opt-in nature of Objective-C type checking.
  • Implement our own array syntax via the klunky FUNCTION() syntax.

I’d prefer the first approach. Hopefully the arrays passed into an expression will tend to be small, so determining the element type would incur only a negligible performance hit.

The first approach jives well with how literal arrays are currently being handled. In the JSON syntax, a literal array is created with ["literal", [x, y, z]], where _x, y, z_ are treated as literal (not expression) values.

This is represented in #9439 by mbgl::style::expression::LiteralExpression. Note that LiteralExpression(..., vector<Value>) does, indeed, iterate over array elements to decide an array literal's type (by way of typeOf(value)), so converting an NSArray node to LiteralExpression should be no problem, as long as we can construct a vector<mbgl::style::expression::Value> convertedValue from the NSArray.

Ah, I missed the distinction between the array and literal expression types. 👍

In the JSON syntax, a literal array is created with ["literal", [x, y, z]], where _x, y, z_ are treated as literal (not expression) values.

In the NSExpression system, an NSArray technically normally holds NSExpressions – this is what you’d get with the inline { x, y, z } format string syntax. However, it’s certainly feasible to pull constant values out of NSExpressions and raise an exception for anything that can’t be converted into a constant value.

This article mentions a couple flaws in NSExpression’s design. I’ve already referred to the klunky function call syntax above, but this article also points out that the ** operator (for exponentiation) is left-binding in NSExpression. This is consistent with Excel and Matlab but inconsistent with normal written convention. So a developer might try to write 232 as 2 ** 3 ** 2, whereas they need to write it as 2 ** (3 ** 2). I don’t think it’s a huge problem, but it’s something to document clearly.

The CAST() function will probably be news to most iOS developers who are otherwise familiar with expressions.

Apparently CAST() can only be used on constant value expressions (aka literals), not key path expressions. So we’ll need to implement type casting via a custom function.

10726 implements a large chunk of this refactoring.

One of the things I’d like to improve ahead of the final release is simplifying the syntax for common operations like string concatenation and interpolation. The current syntax is rather arcane:

NSExpression(format: "FUNCTION('Old', 'stringByAppendingString:', FUNCTION(' ', 'stringByAppendingString:', 'MacDonald'))")

https://github.com/mapbox/mapbox-gl-native/pull/10726#issuecomment-353646221 proposes some alternatives:

  1. Extend the private _NSPredicateUtilities class using the Objective-C runtime:

    NSExpression(format: "mgl_join({'Old', ' ', 'MacDonald'})")
    

    This would result in the most natural usage, but it’s also a bit Clever™. We’ll need to tiptoe around a bit to avoid directly using any private APIs, and we’ll need to include safeguards in case the underlying NSExpression implementation changes at any point.

  2. Overload operators in unconventional ways:

    NSExpression(format: "'Old' & ' ' & 'MacDonald'")
    

    This would only work for a limited number of operations. -expressionValueWithObject:context: will raise an exception on expressions that use these shortcuts. String concatenation is common enough that it would be worth overloading the bitwise and operator & as a shortcut for it. It’s consistent with both Numbers and AppleScript. Studio is also likely to use & as a concatenation operator in its user-friendly expression syntax. On the other hand, we’ll have a problem on our hands if the style specification ever defines a bitwise and operator.

  3. Extend NSExpression with convenience methods and initializers:

    NSExpression(forConstantValue: "Old")
        .mgl_appending(NSExpression(forConstantValue: " "))
        .mgl_appending(NSExpression(forConstantValue: "MacDonald"))
    

    This is the most conventional approach, but it’s also very verbose – even moreso in Objective-C – and difficult to use in conjunction with format strings.

Ideally, we’d implement a combination of all three approaches. However, the added API surface area would be difficult to test comprehensively. We may also need to prioritize one approach over the others due to time constraints. Perhaps we can land #10726 first then work on two or three of these approaches in parallel.

/cc @mapbox/maps-ios @davidtheclark

I'm a fan of using a combination of # 1 (from https://github.com/mapbox/mapbox-gl-native/pull/10726#issuecomment-353646221) and # 3

For # 1, I agree that we'll want some kind of enemy tests or at least integrated tests which have the potential to alert us to changes in the classes we'd be relying on.

In https://github.com/mapbox/mapbox-gl-native/issues/8074#issuecomment-355383677, I used string concatenation as an example, but what should the custom function syntax for interpolation, steps, and coalescing look like? Normally, the function name needs to conform to Objective-C method naming conventions, because NSExpression looks up the function name as a method name on _NSPredicateUtilities when parsing (and calls that method when evaluating the expression). If the method takes multiple parameters, then it starts to depart from the familiar spreadsheet-like function syntax, as with the built-in modulus:by:(1, 2).

mgl_interpolateWithCurveType:parameters:stops: is currently the most verbose function name. Perhaps we can handwave a bit and rename it to INTERPOLATE, with the rationale that TERNARY, also in all-caps, doesn’t name all its arguments.

Would we be able to offer something closer to the structure of MGLStyleFunction for categorical
and interval interpolation modes? Those functions have had the steepest learning curve for me, and may be challenging for users.

Currently, categorical interpolation mode looks something like this:

let colors: [UIColor] = [.orange, .red, .yellow, .blue]

layer.circleColor = NSExpression(format:
    "TERNARY(FUNCTION(type, 'stringValue') = 'earthquake', %@, " +
    "TERNARY(FUNCTION(type, 'stringValue') = 'explosion', %@, " +
    "TERNARY(FUNCTION(type, 'stringValue') = 'quarry blast', %@, " +
    "%@)))",
                                 argumentArray: colors)

That's pretty intimidating when you are less familiar with NSExpression, and took me a while to suss out.

The convenience method could take a stops dictionary for the style and attribute values, then take the default value and attribute value type. Another possibility is to take a dictionary of options containing the default value and attribute value type. Within the method, we can then format and return the NSExpression.

let stops = [ "earthquake" :  UIColor.orange, "explosion"  : UIColor.red, "quarry blast" : UIColor.yellow]

layer.fillOpacity = MGLExpression(withCategories: [name : 1], forAttribute: "name", attributeValueType: "stringValue", withdefaultValue: 0)

Not sure how this would work with more complex expressions, but I implemented something along these lines in one of the examples.

Yes, what I currently have here is just a stopgap to get the tests to build. 😅

I had hoped to do something like this as an intermediate step:

NSExpression(format: "%@[name]", stops)

Unfortunately, the subscripting syntax is only available for indexing into arrays. (NSExpression lacks a syntax for dictionaries as well.) It’s a bit uglier, but for now you might be able to use this syntax instead:

NSExpression(format: "%@.%@", stops, NSExpression(forKeyPath: "name"))

Ultimately, the correct replacement for categorical functions is a case expression. I was held up for awhile by the lack of a good way to express a vararg with the custom function syntax, but the idea at the end of https://github.com/mapbox/mapbox-gl-native/issues/8074#issuecomment-355652845 unblocks that by removing the user expectation that the function would name all the arguments.

One issue that I ran into was with [NSExpression expressionForKeyPath:]. [NSObject valueForKeyPath:] is also an option (thanks to autocomplete). Using it in an example led to this exception:

Terminating app due to uncaught exception ‘NSUnknownKeyException’, reason: ‘[ valueForUndefinedKey:]: this class is not key value coding-compliant for the key height.’

Oof, yes, I think this could be a bit of a trap for people who:

  1. are coding in Objective-C, which has factory methods;
  2. have used the runtime styling API in previous releases, in which everything is expressed in terms of “values”; and
  3. are using NSExpression’s structured methods instead of format strings

I don’t think there’s anything we can do about this confusion at a technical level, since NSExpression is a Foundation class and the parameter to -expressionForKeyPath: is necessarily freeform. But we can sprinkle “not to be confused with” in guides and examples to head off this potential problem.

Extend the private _NSPredicateUtilities class using the Objective-C runtime

Tracking in #11015.

Extend NSExpression with convenience methods and initializers

Tracking in #11016.

Ultimately, the correct replacement for categorical functions is a case expression.

Tracking case in #11007 and the even more streamlined match in #11009.

Overload operators in unconventional ways

Tracking in #11053.

I have problem to customise polyline , width and color . Please see the question in the Stackoveflow.

Was this page helpful?
0 / 5 - 0 ratings