Архітектура / композиція програми у F #


76

Нещодавно я робив SOLID у C # до досить екстремального рівня, і в якийсь момент зрозумів, що по суті я не займаюся нічим іншим, як компонуванням функцій сьогодні. І після того, як я нещодавно знову почав розглядати F #, я зрозумів, що це, мабуть, був би набагато більш доречним вибором мови для більшості того, що я зараз роблю, тому я хотів би спробувати перенести реальний проект C # на F # як доказ концепції. Я думаю, що міг би отримати реальний код (дуже неідіоматично), але я не уявляю, як би виглядала архітектура, яка дозволяє мені працювати так само гнучко, як у C #.

Я маю на увазі це те, що у мене є багато маленьких класів та інтерфейсів, які я складаю за допомогою контейнера IoC, і я також багато використовую такі шаблони, як Decorator та Composite. Це призводить до (на мій погляд) дуже гнучкої та розвинутої загальної архітектури, яка дозволяє мені легко замінити або розширити функціональність у будь-якій точці програми. Залежно від того, наскільки великою є необхідна зміна, мені може знадобитися лише написати нову реалізацію інтерфейсу, замінити його в реєстрації IoC і закінчити. Навіть якщо зміни більші, я можу замінити частини графіку об’єктів, тоді як решта програми просто стоїть так, як це було раніше.

Тепер у F # у мене немає класів та інтерфейсів (я знаю, що можу, але я думаю, що це ще не той момент, коли я хочу займатись фактичним функціональним програмуванням), у мене немає введення конструктора і у мене немає IoC контейнери. Я знаю, що можу робити щось на зразок шаблону "Декоратор", використовуючи функції вищого порядку, але це, схоже, не дає мені такої ж гнучкості та ремонтопридатності, як класи з введенням конструктора.

Розглянемо ці типи C #:

public class Dings
{
    public string Lol { get; set; }

    public string Rofl { get; set; }
}

public interface IGetStuff
{
    IEnumerable<Dings> For(Guid id);
}

public class AsdFilteringGetStuff : IGetStuff
{
    private readonly IGetStuff _innerGetStuff;

    public AsdFilteringGetStuff(IGetStuff innerGetStuff)
    {
        this._innerGetStuff = innerGetStuff;
    }

    public IEnumerable<Dings> For(Guid id)
    {
        return this._innerGetStuff.For(id).Where(d => d.Lol == "asd");
    }
}

public class GeneratingGetStuff : IGetStuff
{
    public IEnumerable<Dings> For(Guid id)
    {
        IEnumerable<Dings> dingse;

        // somehow knows how to create correct dingse for the ID

        return dingse;
    }
}

Я розповім мій IoC контейнер , щоб вирішити AsdFilteringGetStuffдля IGetStuffі GeneratingGetStuffдля її власної залежності з цим інтерфейсом. Тепер, якщо мені потрібен інший фільтр або взагалі видалити фільтр, мені може знадобитися відповідна реалізація, IGetStuffа потім просто змінити реєстрацію IoC. Поки інтерфейс залишається незмінним, мені не потрібно чіпати речі всередині програми. OCP та LSP, увімкнені DIP.

Тепер що мені робити у F #?

type Dings (lol, rofl) =
    member x.Lol = lol
    member x.Rofl = rofl

let GenerateDingse id =
    // create list

let AsdFilteredDingse id =
    GenerateDingse id |> List.filter (fun x -> x.Lol = "asd")

I love how much less code this is, but I lose flexibility. Yes, I can call AsdFilteredDingse or GenerateDingse in the same place, because the types are the same - but how do I decide which one to call without hard coding it at the call site? Also, while these two functions are interchangeable, I now cannot replace the generator function inside AsdFilteredDingse without changing this function as well. This isn't very nice.

Next attempt:

let GenerateDingse id =
    // create list

let AsdFilteredDingse (generator : System.Guid -> Dings list) id =
    generator id |> List.filter (fun x -> x.Lol = "asd")

Now I have composability by making AsdFilteredDingse a higher order function, but the two functions are not interchangeable anymore. On second thought, they probably shouldn't be anyway.

What else could I do? I could mimic the "composition root" concept from my C# SOLID in the last file of the F# project. Most files are just collections of functions, then I have some kind of "registry", which replaces the IoC container, and finally there is one function that I call to actually run the application and that uses functions from the "registry". In the "registry", I know I need a function of type (Guid -> Dings list), which I'll call GetDingseForId. This is the one I call, never the individual functions defined earlier.

For the decorator, the definition would be

let GetDingseForId id = AsdFilteredDingse GenerateDingse

To remove the filter, I'd change that to

let GetDingseForId id = GenerateDingse

The downside(?) of this is that all functions that use other functions would sensibly have to be higher order functions, and my "registry" would have to map all functions that I use, because the actual functions defined earlier can't call any functions defined later, in particular not those from the "registry". I might also run into circular dependency issues with the "registry" mappings.

Does any of this make sense? How do you really build an F# application to be maintainable and evolvable (not to mention testable)?

Відповіді:


59

This is easy once you realize that Object-Oriented Constructor Injection corresponds very closely to Functional Partial Function Application.

First, I'd write Dings as a record type:

type Dings = { Lol : string; Rofl : string }

In F#, the IGetStuff interface can be reduced to a single function with the signature

Guid -> seq<Dings>

A client using this function would take it as a parameter:

let Client getStuff =
    getStuff(Guid("055E7FF1-2919-4246-876E-1DA71980BE9C")) |> Seq.toList

The signature for the Client function is:

(Guid -> #seq<'b>) -> 'b list

As you can see, it takes a function of the target signature as input, and returns a list.

Generator

The generator function is easy to write:

let GenerateDingse id =
    seq {
        yield { Lol = "Ha!"; Rofl = "Ha ha ha!" }
        yield { Lol = "Ho!"; Rofl = "Ho ho ho!" }
        yield { Lol = "asd"; Rofl = "ASD" } }

The GenerateDingse function has this signature:

'a -> seq<Dings>

This is actually more generic than Guid -> seq<Dings>, but that's not a problem. If you only want to compose the Client with GenerateDingse, you could simply use it like this:

let result = Client GenerateDingse

Which would return all three Ding values from GenerateDingse.

Decorator

The original Decorator is a little bit more difficult, but not much. In general, instead of adding the Decorated (inner) type as a constructor argument, you just add it as a parameter value to a function:

let AdsFilteredDingse id s = s |> Seq.filter (fun d -> d.Lol = "asd")

This function has this signature:

'a -> seq<Dings> -> seq<Dings>

That's not quite what we want, but it's easy to compose it with GenerateDingse:

let composed id = GenerateDingse id |> AdsFilteredDingse id

The composed function has the signature

'a -> seq<Dings>

Just what we're looking for!

You can now use Client with composed like this:

let result = Client composed

which will return only [{Lol = "asd"; Rofl = "ASD";}].

You don't have to define the composed function first; you can also compose it on the spot:

let result = Client (fun id -> GenerateDingse id |> AdsFilteredDingse id)

This also returns [{Lol = "asd"; Rofl = "ASD";}].

Alternative Decorator

The previous example works well, but doesn't really Decorate a similar function. Here's an alternative:

let AdsFilteredDingse id f = f id |> Seq.filter (fun d -> d.Lol = "asd")

This function has the signature:

'a -> ('a -> #seq<Dings>) -> seq<Dings>

As you can see, the f argument is another function with the same signature, so it more closely resembles the Decorator pattern. You can compose it like this:

let composed id = GenerateDingse |> AdsFilteredDingse id

Again, you can use Client with composed like this:

let result = Client composed

or inline like this:

let result = Client (fun id -> GenerateDingse |> AdsFilteredDingse id)

For more examples and principles for composing entire applications with F#, see my on-line course on Functional architecture with F#.

For more about Object-Oriented Principles and how they map to Functional Programming, see my blog post on the SOLID principles and how they apply to FP.


5
Thanks Mark for the extensive answer. What I am still unclear on, however, is where to compose in an application with more than a trivial number of functions. I have in the mean time ported a small C# application to F# and posted it here for review; I have implemented the mentioned "registry" there as a Composition module, essentially a "poor man's DI" composition root. Is that a viable way to go?
TeaDrivenDev

@TeaDrivenDev As you may have noticed, I've updated the post by adding a link to a blog post that digs a bit deeper. When it comes to composition, one of the interesting things about F# is that the compiler prevents circular references, so composition and decoupling is a lot safer than in C#. The code you've linked to composes towards the end, so that looks good.
Mark Seemann

An alternate definition of the functions could be: let AdsFilteredDingse = Seq.filter (fun d -> d.Lol = "asd") and let composed = GenerateDingse >> AdsFilteredDingse. This way you get to use the function composition operator and you can leave out the id from AdsFilteredDingse. But again, as Mark mentioned, it's not really a decorator in the original OO sense of the word.
Simon Stender Boisen

I've tried that, but it gives me a compiler error, although the signature of composed looks good: "error FS0030: Value restriction. The value 'composed' has been inferred to have generic type val composed : ('_a -> seq<Dings>) Either make the arguments to 'composed' explicit or, if you do not intend for it to be generic, add a type annotation."
Mark Seemann
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.