dfed / safedi Goto Github PK
View Code? Open in Web Editor NEWCompile-time safe dependency injection in Swift
License: MIT License
Compile-time safe dependency injection in Swift
License: MIT License
Objectives
Enhance the ease of declaring instantiators.
Discussion
With the recent update to SafeDI 0.5.0, to create an insatiable builder, users need to perform two steps:
@Instantiable
macro to the desired concrete type.Instantiable
.Proposal
Could SafeDI automatically generate the conformance using macro code generation, eliminating the need for users to manually add it?
@Instantiable
public class ConcreteType {
...
}
Generated Code
extension ConcreteType: Instantiable { }
Steps to reproduce
final
Instantiable
-decorated type must be public
or open
public class XYZ
instead of public final class XYZ
Expected behavior
Fixit applies public class
Actual behavior
Fixit applies public final class
Goals
Non-Goals
@Received(fulfilledByDependencyNamed:ofType:)
works today).Investigations
This can be accomplished today by utilizing a @Instantiated
extension whose instantiate()
method always returns the same instance for multiple types, but this is a limiting way to accomplish the above goals since it ensures that we'll only ever have a single instance, which might not be what we want.
Right now, the only thing preventing roots from having an aliased received property is our own code that identifies roots.
Design
The design here would enable a root object to declare a @Received(fulfilledByDependencyNamed:ofType:)
for a property that is @Instantiated
on the same type. This will require updating DependencyTreeGenerator
's lazy var possibleRootInstantiableTypes
implementation, or alternatively updating the name and implementation of fileprivate var areAllInstantiated: Bool
.
One thing to consider is that each of a possible root type's @Received(fulfilledByDependencyNamed:ofType:)
properties must refer to @Instantiated
property declared on that same Instantiable
struct. Doing this check performantly will require creating a Set
of an Instantiable
's dependencies. We can likely create this set just-in-time and throw it away, since we don't have another use for this Set
just yet, and other code currently relies on the ordering of public let dependencies: [Dependency]
on Instantiable
.
The PR that implements this issue should have the following tests added to SafeDIToolTests
:
func test_run_writesConvenienceExtensionOnRootOfTree_whenRootHasReceivedAliasOfInstantiable() async throws {
let output = try await executeSystemUnderTest(
swiftFileContent: [
"""
@Instantiable
public struct Root {
@Instantiated
private let defaultUserService: DefaultUserService
@Received(fulfilledByDependencyNamed: "defaultUserService", ofType: DefaultUserService.self)
private let userService: any UserService
}
""",
"""
import Foundation
public protocol UserService {
var userName: String? { get set }
}
@Instantiable(fulfillingAdditionalTypes: [UserService.self])
public final class DefaultUserService: UserService {
public init() {}
public var userName: String?
}
""",
],
buildDependencyTreeOutput: true
)
XCTAssertEqual(
try XCTUnwrap(output.dependencyTree),
"""
// This file was generated by the SafeDIGenerateDependencyTree build tool plugin.
// Any modifications made to this file will be overwritten on subsequent builds.
// Please refrain from editing this file directly.
#if canImport(Foundation)
import Foundation
#endif
extension Root {
public init() {
let defaultUserService: DefaultUserService = DefaultUserService()
let userService: any UserService = defaultUserService
self.init(defaultUserService: defaultUserService, userService: userService)
}
}
"""
)
}
func test_run_successfullyGeneratesOutputFileWhenNoRootFound() async throws {
let output = try await executeSystemUnderTest(
swiftFileContent: [
"""
@Instantiable
public struct NotRoot {
@Instantiated
private let defaultUserService: DefaultUserService
// This received property's alias is improperly configured, meaning that this type is not a root.
@Received(fulfilledByDependencyNamed: "userService", ofType: DefaultUserService.self)
private let userService: any UserService
}
""",
"""
import Foundation
public protocol UserService {
var userName: String? { get set }
}
@Instantiable(fulfillingAdditionalTypes: [UserService.self])
public final class DefaultUserService: UserService {
public init() {}
public var userName: String?
}
""",
],
buildDependencyTreeOutput: true
)
XCTAssertEqual(
try XCTUnwrap(output.dependencyTree),
"""
// This file was generated by the SafeDIGenerateDependencyTree build tool plugin.
// Any modifications made to this file will be overwritten on subsequent builds.
// Please refrain from editing this file directly.
// No root @Instantiable-decorated types found, or root types already had a `public init()` method.
"""
)
}
This PR should also include an update to the README to mention this capability! This may be the toughest part of the task.
Today, a Container<Element>
type cannot effectively be @Instantiable
or @Instantiated
unless an init
method can be written that does not utilize the generic Element
Ideally, it would be possible for a type with a generic to be @Instantiable
. Imagine something like:
@Instantiable
extension Container: Instantiable {
public static func instantiate() -> Container<Bool> {
.init(contained: false)
}
public static func instantiate() -> Container<Int> {
.init(contained: 0)
}
public static func instantiate() -> Container<String> {
.init(contained: "")
}
}
Accomplishing this should be relatively straight-forward. We currently have a FixableInstantiableError.incorrectReturnType
that throws whenever the return type's description is not the same as the extended type. We could make this error more permissive and not throw as long as the base name (i.e. the type name without generics) is the same.
I don't see a good way to make generics instantiable outside of an extension
, but I am open to ideas!
When SafeDI was written, it was not possible for a Swift Package Plugin to depend on the output of a plugin run in a dependent module. In Swift 6, this is now possible by using the pluginGeneratedResources
property on SwiftSourceModuleTarget
.
This feature will enable extremely large codebases to adopt SafeDI without experiencing performance impacts or building execution tooling outside of SPM.
Goals
Non-Goals
Design
Our generated code today has a fundamental flaw which does not enable the lazy instantiation of cycles of dependencies: properties that fulfill lazy instantiation can not be called from within their own definition. Here's how a type A which lazily creates B which lazily creates C which lazily creates A fails to compile with our current code generation:
extension Root {
public convenience init() {
let aBuilder = Instantiator<A> {
let bBuilder = Instantiator<B> {
let cBuilder = Instantiator<C> {
C(aBuilder: aBuilder) // Fails to compile! We can't reference aBuilder from within aBuilder's definition.
}
return B(cBuilder: cBuilder)
}
return A(bBuilder: bBuilder)
}
self.init(aBuilder: aBuilder)
}
}
To enable the lazy creation of dependency cycles, we need a way to reference a builder from within the builder's definition. While a property can't be referenced from within a closure that instantiates the property, a function can. Here's how a type A which lazily creates B which lazily creates C which lazily creates A could be written in a way that compiles:
extension Root {
public convenience init() {
func __safeDI_aBuilder() -> A {
func __safeDI_bBuilder() -> B {
func __safeDI_cBuilder() -> C {
let aBuilder = Instantiator<A>(__safeDI_aBuilder)
return C(aBuilder: aBuilder)
}
let cBuilder = Instantiator<C>(__safeDI_cBuilder)
return B(cBuilder: cBuilder)
}
let bBuilder = Instantiator<B>(__safeDI_bBuilder)
return A(bBuilder: bBuilder)
}
let aBuilder = Instantiator<A>(__safeDI_aBuilder)
self.init(aBuilder: aBuilder)
}
}
When ScopeGenerator
‘s func generateCode
encounters an instantiator or forwarding instantiator property type, we should generate the builder method, and then declare the property that calls into the defined builder method. When a property declares a builder that forms the re-entry into the cycle, we should declare the property that calls into the builder method immediately, knowing that the call will compile + recurse.
Functions to fulfill forwarding instantiators will declare arguments matching the forwarded properties.
Where we detect and throw ScopeError.dependencyCycleDetected
today, we'll instead need to create and return a ScopeGenerator
that is aware that the builder function it needs to re-enter the cycle is already defined.
Steps to reproduce
Write the following type:
@Instantiable
public struct ClosureContainer {
@Forwarded
let closure: () -> Void
}
Expected behavior
SafeDI would generate an initializer:
public init(closure: @escaping () -> Void) {
self.closure = closure
}
Actual behavior
SafeDI generates an initializer that does not compile because the closure is not escaping:
public init(closure: () -> Void) {
self.closure = closure
}
Worse yet, SafeDI generates this initializer even if you have written the following initializer:
public init(closure: @escaping () -> Void) {
self.closure = closure
}
This means it is not possible to have a @Forwarded
closure work with SafeDI today
Offending code lives in Initializer.generateRequiredInitializer
. A PR addressing this issue should add the following test:
func test_declaration_generatesRequiredInitializerWithClosureDependency() {
assertMacro {
"""
@Instantiable
public struct ExampleService {
@Forwarded
let closure: () -> Void
}
"""
} expansion: {
"""
public struct ExampleService {
let closure: () -> Void
// A generated initializer that has one argument per SafeDI-injected property.
// Because this initializer is generated by a Swift Macro, it can not be used by other Swift Macros.
// As a result, this initializer can not be used within a #Preview macro closure.
// This initializer is only generated because you have not written this macro yourself.
// Copy/pasting this generated initializer into your code will enable this initializer to be used within other Swift Macros.
nonisolated public init(closure: @escaping () -> Void) {
self.closure = closure
}
}
"""
}
}
Goals
Make it less painful to debug when you refer to a type in an Instantiator
by two different names.
Design
Give a more actionable error when we have a type that doesn't match anything, but there's an existing type that is the same name prefixed by another type. For example:
@Instantiated private let someThing: Instantiated<View>
@Received private let someThing: Instantiated<MyViewController.View>
Goals
Take an example class:
@Instantiable
public final class MetricsService {
@Forwarded
private let user
}
If a class wants to instantiate MetricsService
, they must use this declaration:
@Instantiated
private let metricsServiceBuilder: ForwardingInstantiator<MetricsService.ForwardedArguments, MetricsService>
However, SafeDI can easily generate another typealias, similar to forwardedArguments
which makes this declaration shorter:
@Instantiated
private let metricsServiceBuilder: MetricsService.ForwardingInstantiator
Which would look like this in the codegen:
extension MetricsService {
typealias ForwardingInstantiator = FowardingInstantiator</* forwarded types here */, MetricsService>
}
Goals
Better ways to visualize your dependency injection graph and understand how a dependency got somewhere, what all uses a dependency, etc.
Non-Goals
N/A
Investigations
N/A
Design
Some prior art in usage patterns from cocoapods-why: https://github.com/square/cocoapods-why
Steps to reproduce
Sample code or sample project upload
@MainActor
@Instantiable
public final class ApplicationCoordinator: NSObject {
private(set) var window: UIWindow?
// MARK: - Initializers
nonisolated
public init(
featureFlagManager: FeatureFlagManager
) {
self.featureFlagManager = featureFlagManager
super.init()
// Do other work here
}
@Instantiated
private let featureFlagManager: FeatureFlagManager
Now results in an error:
Main actor-isolated property featureFlagManager can not be mutated from a non-isolated context; this is an error in Swift 6
Today, we have good dependency cycle error messages when there's an @Instantiated
cycle, but our error message when a cycle involves a @Received
property could use some work. Today, the following test fails:
func test_run_onCodeWithCircularPropertyDependenciesImmediatelyInitializedAndReceived_throwsError() async {
await assertThrowsError(
"""
Dependency cycle detected!
A -> B -> C -> A
"""
) {
try await executeSafeDIToolTest(
swiftFileContent: [
"""
@Instantiable
public final class Root {
@Instantiated let a: A
}
""",
"""
@Instantiable
public final class A {
@Instantiated let b: B
}
""",
"""
@Instantiable
public final class B {
@Instantiated let c: C
}
""",
"""
@Instantiable
public final class C {
@Received let a: A
}
""",
],
buildDependencyTreeOutput: true,
filesToDelete: &filesToDelete
)
}
}
Because it produces error message:
@Received property `a: A` is not @Instantiated or @Forwarded in chain: Root -> A -> B -> C
We should instead make this test pass.
Today, if you have builders (Instantiator<...>
types) that form a cycle, you must @Instantiate
the cycle'd builder rather than @Receive
-ing it. If you try to @Receive
the cycle builder, you get an error that the builder doesn't exist in the chain. This is technically incorrect – it does exist in the chain, but it's inaccessible in the code-gen due to the cycle.
To address this technical deficiency, we should enable this test to pass:
func test_run_writesConvenienceExtensionOnRootOfTree_whenLazyInstantiationCycleExistsAndCycleBuilderIsReceived() async throws {
let output = try await executeSafeDIToolTest(
swiftFileContent: [
"""
@Instantiable
public struct Root {
@Instantiated let aBuilder: Instantiator<A>
}
""",
"""
@Instantiable
public struct A {
@Instantiated let bBuilder: Instantiator<B>
}
""",
"""
@Instantiable
public struct B {
@Instantiated let cBuilder: Instantiator<C>
}
""",
"""
@Instantiable
public struct C {
@Received let aBuilder: Instantiator<A>
}
""",
],
buildDependencyTreeOutput: true,
filesToDelete: &filesToDelete
)
XCTAssertEqual(
try XCTUnwrap(output.dependencyTree),
"""
// This file was generated by the SafeDIGenerateDependencyTree build tool plugin.
// Any modifications made to this file will be overwritten on subsequent builds.
// Please refrain from editing this file directly.
extension Root {
public init() {
func __safeDI_aBuilder() -> A {
func __safeDI_bBuilder() -> B {
func __safeDI_cBuilder() -> C {
let aBuilder = Instantiator<A>(__safeDI_aBuilder)
return C(aBuilder: aBuilder)
}
let cBuilder = Instantiator<C>(__safeDI_cBuilder)
return B(cBuilder: cBuilder)
}
let bBuilder = Instantiator<B>(__safeDI_bBuilder)
return A(bBuilder: bBuilder)
}
let aBuilder = Instantiator<A>(__safeDI_aBuilder)
self.init(aBuilder: aBuilder, notCyclic: notCyclic)
}
}
"""
)
}
Steps to reproduce
Write the following type:
@Instantiable
public struct ExampleService {}
Expected behavior
The following initializer would be generated:
public init() {}
Actual behavior
The following initializer is generated:
nonisolated public init() {}
Ideally, the initializer would be marked as nonisolated
only if the type is decorated with @MainActor
. The offending code lives in ConcreteDeclType.initializerModifiers
. When this issue is addressed, I would expect a test similar (or identical) to the below to be included in the PR:
func test_declaration_generatesRequiredInitializerOnMainActorBoundClass() {
assertMacro {
"""
@MainActor
@Instantiable
public class ExampleService {
}
"""
} expansion: {
"""
@MainActor
public class ExampleService {
// A generated initializer that has one argument per SafeDI-injected property.
// Because this initializer is generated by a Swift Macro, it can not be used by other Swift Macros.
// As a result, this initializer can not be used within a #Preview macro closure.
// This initializer is only generated because you have not written this macro yourself.
// Copy/pasting this generated initializer into your code will enable this initializer to be used within other Swift Macros.
nonisolated public init() {
}
}
"""
}
}
I would also expect test_declaration_generatesRequiredInitializerWithoutAnyDependenciesOnClass()
(and possibly a few others) to be updated to remove the nonisolated
keyword from generated code.
Note that this issue does not lead to compilation errors. Addressing this issue is an enhancement rather than a bugfix.
@Instantiated
property that is fulfilled by another type in the declared type-erased type's initializer when appropriate@Received
property in the declared type-erased type's initializer when appropriateToday, we support type erasure with UIKit by enabling a property to return a type further up in the inheritance tree than the object being created. e.g. it is possible to have a Instantiator<UIViewController>
when we are actually creating a subclass of UIViewController
However, because SwiftUI both does not have an inheritance tree and has generics, a Instantiator<View>
fails to compile. Instantiator<any View>
can compile, but is useless since an AnyView
can't take an any View
in its initializer, and therefore AnyView(myAnyViewInstantiator.instantiate())
fails to compile. Similarly, Instantiator<some View>
fails to compile because there is no initialization clause.
It is standard practice in Swift libraries for an Any*
type to have a no-label initializer. However, AnyActor
is an exception to this standard practice.
The initial proposed design is simple: any @Instantiated(fulfilledByType:)
or @Received(fulfilledByDependencyNamed:ofType:)
property where the returned instance is a type that has an Any
prefix will have the generated code that fulfills that type wrapped in the AnyType
's initializer.
In the following example, a type-erased noteViewBuilder
@Instantiated(fulfilledByType: "NoteView")
private let noteViewBuilder: ForwardingInstantiator<String, AnyView>
Would have the following code generated for it:
func __safeDI_noteViewBuilder(userName: String) -> AnyView {
AnyView(NoteView(userName: userName, userService: userService, stringStorage: stringStorage))
}
We would know to do this because the concrete returned type of the forwarding instantiator is of type AnyView
, which has the prefix Any
. We also know that there is no @Instantiatiable
type AnyView
anywhere in the codebase.
While this design makes the strong assumption that any Any
-prefixed type that is not @Instantiable
is a type eraser with a no-label public init(_:)
method, this is true of every Apple-made Any
-prefixed type outside of AnyActor
. Since making a strong assumption that is already proven incorrect is probably not the best approach, we move onto the next idea.
If we want to avoid making a strong assumption, we can instead create two new macros:
public macro Instantiated(fulfilledByErasedType concreteTypeName: StaticString)
public macro Received<T>(fulfilledByDependencyNamed: StaticString, ofErasedType: T.Type)
When these new ErasedType macros are used, we know that we need to wrap the returned instance in our code generation in a no-label initializer of the erasing type.
In the following example, a type-erased noteViewBuilder
@Instantiated(fulfilledByErasedType: "NoteView")
private let noteViewBuilder: ForwardingInstantiator<String, AnyView>
Would have the following code generated for it:
func __safeDI_noteViewBuilder(userName: String) -> AnyView {
AnyView(NoteView(userName: userName, userService: userService, stringStorage: stringStorage))
}
This design is more flexible than the initial design, but has the downside of adding yet another complexity layer to every autocomplete where it may not be necessary. I also don't love the ErasedType
substring in the argument list.
We can reduce the naming and autocomplete complexity in the above option by amending our existing macros to enable this new functionality:
public macro Instantiated(fulfilledByType concreteTypeName: StaticString, erasedToConcreteExistential: Bool = false)
public macro Received<T>(fulfilledByDependencyNamed: StaticString, ofType: T.Type, erasedToConcreteExistential: Bool = false)
When the new erasedToConcreteExistential
macro parameters are used, we know that we need to wrap the returned instance in our code generation in a no-label initializer of the concrete erasing existential type.
In the following example, a type-erased noteViewBuilder
@Instantiated(fulfilledByType: "NoteView", erasedToConcreteExistential: true)
private let noteViewBuilder: ForwardingInstantiator<String, AnyView>
Would have the following code generated for it:
func __safeDI_noteViewBuilder(userName: String) -> AnyView {
AnyView(NoteView(userName: userName, userService: userService, stringStorage: stringStorage))
}
This option has the benefits of both being explicit and having a sensible default option. Having a default option reduces complexity in the common case, which seems beneficial.
I am strongly leaning towards the third design concept.
When we make this change, we should update the README to use this rather than the non-compiling some View
@Instantiable
and @Instantiated
have long common prefixes. It's extremely easy to type the wrong one.
Today, if a type declaration is decorated with @Instantiated
, we throw an error. Instead, we should throw an error with a fix it: "This macro must decorate an instance variable. Did you mean @Instantiable
?" where the FixIt replaces the decoration with the likely appropriate one.
Same in the opposite direction. Decorating a property declaration with @Instantiable
should have a fix it to change the decorator to @Instantiated
.
Goals
Non-Goals
Investigations
It's possible to use @Forwarded
to enable propagating a property under a new name and type, but given that you can only have one forwarded property per @Instantiable
type, this is a limiting approach.
Problem Statement
Take the following code:
public protocol UserProvider {
var user: User { get }
}
public protocol UserManager: UserProvider {
func setUserName(_ userName: String)
}
@Instantiable(fulfillingAdditionalTypes: [UserManager.self])
public final class DefaultUserManager: UserManager {
... // implementation here
}
It should be possible in one place in the DI tree to instantiate a let userManager: UserManager
and further down the DI tree receive a let userProvider: UserProvider
whose instance is the same instance as the userManager
's.
Design
I believe the simplest path forward here is to add an optional parameter to the @Received
macro. Something like:
@Received(fulfilledByDependencyNamed: "userManager")
let userProvider: UserProvider
SafeDI would then know to fulfill the @Received
property with an @Instantiated
or @Forwarded
property further up the tree called userManager
. SafeDI would not be able to type check to ensure that the userManager
property actually conforms to UserProvider
, but if it can't then the generated code would fail to compile in a way that would make the issue fairly obvious.
Note that this design only applies to cases where the received property is of a type the desired dependency already directly conforms to. If UserManager
did not inherit from UserProvider
, we'd be in trouble. We can add more use-at-your-own-risk flexibility to SafeDI's macros by adding yet another optional parameter to the @Recevied
macro:
@Received(fulfilledByDependencyNamed: "userManager", forceCast: true)
let userProvider: UserProvider
This would cause the generated code to force cast the userManager as! UserProvider
. I don't expect we'll introduce the forceCast
option in the near term, but I outline it here to document how we could solve such a problem down the line.
Steps to reproduce
@MainActor
@Instantiate
a property of type X
in your dependency treeX
@Instantiate
a type Instantiator<Y>
where Y
is @MainActor
-boundDue to what looks like a bug in the Swift Compiler – reported in swiftlang/swift#75003 – the generated code fails to compile with strict concurrency checking turned on.
To resolve this issue, we'll need to avoid utilizing anonymous, just-in-time-created-and-executed closures in our code generation. Instead, we'll need to rely on function declarations which do not exhibit this problem.
The aforementioned Swift issue has been reproduced on both Swift 5.10 and Swift 6 in Swift 6 mode.
Goals
Currently if a a class is marked as @.Instantiable and its dependencies are not marked as @.Received, @.Instantiated or @.Forwarded the compiler fails with a generic error saying that it's missing a public initializer
We need the compiler to be more vocal about the missing requirements because error messages like these can be really confusing
I am using the example project integration in the codebase.
NoteView.swift
any UserService
type to Instantiator<any UserService>
Type 'any UserService' does not conform to protocol 'Instantiable'
Sample code or sample project upload
@MainActor
@Instantiable
public struct NoteView: Instantiable, View {
public init(userName: String, userServiceBuilder: Instantiator<any UserService>, stringStorage: StringStorage) {
self.userName = userName
self.userServiceBuilder = userServiceBuilder
self.stringStorage = stringStorage
_note = State(initialValue: stringStorage.string(forKey: userName) ?? "")
}
public var body: some View {
VStack {
Text("\(userName)’s note")
TextEditor(text: $note)
.onChange(of: note) { _, newValue in
stringStorage.setString(newValue, forKey: userName)
}
Button(action: {
userService.userName = nil
}, label: {
Text("Log out")
})
}
.padding()
}
@Forwarded
private let userName: String
@Instantiated
private let userServiceBuilder: Instantiator<any UserService>
@Received
private let stringStorage: StringStorage
private lazy var userService = userServiceBuilder.instantiate()
@State
private var note: String = ""
}
#Preview {
NoteView(
userName: "dfed",
userService: DefaultUserService(stringStorage: UserDefaults.standard),
stringStorage: UserDefaults.standard
)
}
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.