SE-0389: Attached Macros

Hello Swift community,

The review of SE-0389: "Attached Macros" begins now and runs through March 2, 2023.

Reviews are an important part of the Swift evolution process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to the review manager. When emailing the review manager directly, please keep the proposal link at the top of the message.

What goes into a review?

The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the direction of Swift. When writing your review, here are some questions you might want to answer in your review:

  • What is your evaluation of the proposal?
  • Is the problem being addressed significant enough to warrant a change to Swift?
  • Does this proposal fit well with the feel and direction of Swift?
  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

More information about the Swift evolution process is available at

https://github.com/apple/swift-evolution/blob/main/process.md

Thank you,

—Tony Allevato
Review Manager

8 Likes

In general, I love this proposal, and look forward to using it to do in-language a variety of things that currently require code generation.

We seem to have lost "witness macros" since the original pitch — the form of attached macro that would allow third-party protocols to get the special treatment afforded to Equatable, Hashable, Codable, etc.

The functionality still seems to be present, by using a combined "conformance" and "member" macro, but I do wish that we still had syntactic parity with the language features:

@SynthesizeDiscriminant
enum MyEnumWithAssociatedValues: Hashable {
    case bool(Bool)
    case int(Int)
}

vs.

enum MyEnumWithAssociatedValues: Discriminable, Hashable {
    case bool(Bool)
    case int(Int)
}

just draws attention to the "third-party" nature of the macro, and decouples the macro from the protocol it adds conformance to, since the macro and the protocol live in the same namespace.

1 Like

Witness macros as that show up in the vision document (and earlier pitches) have a different-enough design from these attached macros that I'd like to consider them separately. I agree that it's a high-value kind of macro for us to address, though.

Doug

8 Likes

A macro that fills multiple roles cannot communicate the results of context.createUniqueName across those roles, as far as I can tell. I think this could end up being a serious shortcoming.

For example, I wanted to write this accessor & peer macro:

/// I turn this syntax:
///
/// ```
/// extension EnvironmentValues {
///   @AutoEnvironmentKey
///   public var myValue: String = "don't abuse macros"
/// }
/// ```
///
/// into this syntax:
///
/// ```
/// extension EnvironmentValues {
///   public var myValue: String {
///     get { self[__generated_name__0xc0deface.self] }
///     set { self[__generated_name__0xc0deface.self] = newValue }
///   }
///
///   private enum __generated_name__0xc0deface: EnvironmentKey {
///     static var defaultValue: String { "don't abuse macros" }
///   }
/// }
/// ```

@attached(accessor)
@attached(peer)
public macro AutoEnvironmentKey() = #externalMacro(module: "MacroExamplesPlugin", type: "AutoEnvironmentKeyMacro")

As you might guess from the example, it should generate a unique name for the EnvironmentKey-conforming type. But it can't, because the expansion method for the AccessorMacro conformance has no way to rendezvous with the expansion method for the PeerMacro conformance.

3 Likes

What is your evaluation of the proposal?

This is great. I love the balance of power and expressiveness. The independent application of the various roles will be a great help to understanding the effects of a multi-role macro.

Is the problem being addressed significant enough to warrant a change to Swift?

Yes. This will empower library authors to produce even more powerful tools without burdening clients with boilerplate.

Does this proposal fit well with the feel and direction of Swift?

Yes. The guard rails provided by macro roles feel appropriate to the language.

The new shadowing possible by replacing a macro with its expansion is a bit odd. It doesn’t strike me as something that will be problematic in practice, though folks working on tooling that allows in-place visualization of expansions might need to worry about this corner case.

If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

Scheme/Racket macros don’t really compare, since Swift isn’t s-expression-based. Java attributes are superficially similar, but I understand that such decorator annotations are for a separate proposal.

How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

I studied the proposal, the Vision document, and the expression macros proposal.

A nit:

In the example expansion of the AddCompletionHandler macro…

func fetchAvatar(_ username: String, onCompletion: @escaping (Image?) -> Void) {
  Task.detached {
    completionHandler(await fetchAvatar(username))
  }
}

…shouldn’t the closure invoked inside the detached task be named onCompletion? I was confused here trying to reconcile the default argument in the macro definition vs. the given argument in the macro application.

Yes, this is a typo in the proposal. I've put up a PR to fix this here: [SE-0389] Fix typo in completion handler example. by hborla · Pull Request #1955 · apple/swift-evolution · GitHub

2 Likes

I’m not sure if this is the right place for my suggestion, but I think a good future direction could be a flag passed to Swift build which shows you the expanded code. E.g. swift build —show-expanded MyStruct.propertyWrappedDecl.

1 Like

If @attached is only valid when applied to a macro declaration, why is it not spelled macro(attached)? Or, following the examples of operators, attached macro?

Edited to elaborate:

Over the past few Swift releases, there’s been a rapid growth in the attribute namespace and in the appearance of @ in “normal” programs.

Every builtin attribute is a potential clash with a builder, property wrapper, or now macro. Since macros are poised to subsume the functionality of these other features, they represent something of a “terminal evolution state”, and I think it’s worth thinking ahead to avoid a situation like ISO C’s arcane rules about reserved symbol names.

Thinking back to the old operator syntax in which precedence and associativity were declared as members:

macro SomeMacro {
  attached(member, names: foo, *)
}

I also slipped in a proposed tweak to the name syntax. By using * for arbitrary, analogously to @available, the need for named() is hopefully eliminated.

2 Likes

Does the -Xfrontend -dump-macro-expansions flag discussed in SE-0382: Expression Macros meet your needs or are you looking for something else?

This came up in the first review of SE-0283:

This hasn't really been a problem in practice with type-based custom attributes because of the capitalization in the naming convention for nominal types. Perhaps we should recommend the same naming convention for attached macros.

This is an interesting idea, but I think the attributed model is the right way to think about macro roles. If anything, I think macro declarations could express their overloads as "member" declarations, e.g.:

@attached(member, ...)
macro SomeMacro {
  // each 'init' declaration describes the parameters that can be used in the macro attribute

  init(_: Int) = #externalMacro(/*some external macro*/)

  init(_ : Double) = #externalMacro(/*some other external macro*/)
}

This model would allow the compiler to resolve macros to a macro declaration without performing overload resolution, but this is orthogonal to your comments about macro roles.

This does not cover overloaded, prefixed and suffixed. I don't think we can eliminate named.

Yes, I wasn’t aware of the flag, but it seems like a good starting point. Nevertheless, with attached macros there’s an opportunity to identify specific declarations to be expanded (e.g. expanding only a property wrapper declaration).

FWIW, we added a refactoring action to SourceKit to expand the macro under the cursor. I consider this to be a much better model than "expand all the macros at once".

Doug

8 Likes

Right, you would probably use prefixed or suffixed so the two different roles could agree on a way to spell the private enum name based on the property name. For your example, that might even be better than the unique name, because the resulting expanded code would more clearly identify the link between property and enum.

Doug

1 Like

My overall opinion is very positive and hasn't changed much since the pitch:

Here are two things I want to specifically point out:

  • The problem of shadowing the default memberwise initializer:

  • A peer macro currently can either specify that it generates a prefixed name or a suffixed name. I can see situations where a macro would want to add both a prefix and a suffix. For example, a macro attached to the type APIClient could generate a type called _APIClientMock. It would have to use arbitrary, which would lead to longer compilation times.

    Maybe, instead of having prefixed() and suffixed(), we could have something like modified(prefix:suffix:) where either argument could be omitted (i.e. modified(prefix:) or modified(suffix:)).

Attached macros are not limited to only one of the supported name forms. A peer macro can add both prefixed and suffixed names, e.g.

@attached(peer, names: prefixed(prefix_), suffixed(_suffix))
macro GeneratePrefixedAndSuffixed = #externalMacro(...)

// The macro generates two peers: one named 'prefix_value' and another
// named 'value_suffix'
@ GeneratePrefixedAndSuffixed
var value: Int

Thanks for your response. I understand that. My example was that a macro would generate a single peer declaration with both a prefix and a suffix (e.g. prefix_varName_suffix for a base declaration named varName).

1 Like

Ah, I misunderstood, thank you for clarifying!

1 Like

I feel like that discussion settled on it being correct to disable synthesis of the memberwise init when a macro creates an init" (because it follows the rules as-if you'd written the code without the macro), but that we might consider some kind of @doesNotSuppressMemberwiseInit attribute separately.

Interesting. If we can't model this directly, it means using arbitrary for such names, which is a pessimization for compile times, but shouldn't affect what a macro can do. I'm ambivalent about this, other than wanting a name more descriptive than modified if we do it.

Doug

Although I think the currently proposed scheme for describing names is acceptable, I think that as a future direction, we could adopt something a little more unified and lighter on keywords:

(? is a semi-arbitrary choice; we could use something else.)

This scheme could handle anything proposed here, as well as a couple other things:

identifier-spec syntax Proposed syntax
rawValue named(rawValue)
* arbitrary
_* (no equivalent, use arbitrary)
*Mock (no equivalent, use arbitrary)
_*Mock (no equivalent, use arbitrary)
? overloaded
_? prefixed(_)
?Mock suffixed(Mock)
_?Mock (no equivalent, use arbitrary)

It might further make sense to allow * wildcards before and/or after the ? so that you can specify patterns like _?For*Mock.

But all of this seems like an additive extension to what’s already specified in SE-0389. (You might have to backtick arbitrary and overloaded when using the new syntax, though.) I think you’ve proposed something we can accept; I just wanted to write down this alternative so the idea doesn’t get lost.

This proposal has been accepted. Thanks to everyone who participated in the review!

3 Likes