-
-
Save cartermp/6b91c3561c6a5efca4288dca37c15edc to your computer and use it in GitHub Desktop.
{ | |
"cells": [ | |
{ | |
"cell_type": "markdown", | |
"metadata": {}, | |
"source": "F# 5.0 is now in preview! You can get it in three ways:\n\n* Installing [.NET 5 preview](https://dotnet.microsoft.com/download/dotnet-core/5.0)\n* Installing [Jupyter Notebooks for .NET](https://github.com/dotnet/interactive/#how-to-install-net-interactive)\n* Installing [Visual Studio Preview (Windows only)](https://visualstudio.microsoft.com/vs/preview/)\n\nFeatures (so far):\n\n* Tolerant and consistent slices for lists, arrays, strings, 3D arrays, and 4d arrays\n* Fixed-index 3D and 4D array slicing\n* \"From the end\" slices and indexes\n* `nameof` function\n* `open` for static classes\n* `#r \"nuget\"` for F# Interactive\n* Applicative computation expresisons (`and!` keyword)\n* Better interop with nullable value types\n\nYou can read about all of the preview feature designs in the [F# language design repository](https://github.com/fsharp/fslang-design/tree/master/preview).\n\nLet's tour some features!" | |
}, | |
{ | |
"cell_type": "markdown", | |
"metadata": {}, | |
"source": "## Package references in F# Interactive\n\nYou can now directly `#r` a package from NuGet with F# Interactive." | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 1, | |
"metadata": {}, | |
"source": [ | |
"#!fsharp\r\n", | |
"#r \"nuget: Newtonsoft.Json\"\r\n", | |
"\r\n", | |
"open Newtonsoft.Json\r\n", | |
"\r\n", | |
"let o = {| X = 2; Y = \"Hello\" |}\r\n", | |
"\r\n", | |
"JsonConvert.SerializeObject o" | |
], | |
"outputs": [] | |
}, | |
{ | |
"cell_type": "markdown", | |
"metadata": {}, | |
"source": "It also works with packages that bring along native dependencies." | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 1, | |
"metadata": {}, | |
"source": [ | |
"#!fsharp\r\n", | |
"#r \"nuget: Flips\"\r\n", | |
"\r\n", | |
"open System\r\n", | |
"open Flips.Domain\r\n", | |
"open Flips.Solve\r\n", | |
"\r\n", | |
"// Declare the parameters for our model\r\n", | |
"let hamburgerProfit = 1.50\r\n", | |
"let hotdogProfit = 1.20\r\n", | |
"let hamburgerBuns = 300.0\r\n", | |
"let hotdogBuns = 200.0\r\n", | |
"let hamburgerWeight = 0.5\r\n", | |
"let hotdogWeight = 0.4\r\n", | |
"let maxTruckWeight = 200.0\r\n", | |
"\r\n", | |
"// Create Decision Variable with a Lower Bound of 0.0 and an Upper Bound of Infinity\r\n", | |
"let numberOfHamburgers = Decision.createContinuous \"NumberOfHamburgers\" 0.0 infinity\r\n", | |
"let numberOfHotdogs = Decision.createContinuous \"NumberOfHotDogs\" 0.0 infinity\r\n", | |
"\r\n", | |
"// Create the Linear Expression for the objective\r\n", | |
"let objectiveExpression = hamburgerProfit * numberOfHamburgers + hotdogProfit * numberOfHotdogs\r\n", | |
"\r\n", | |
"// Create an Objective with the name \"MaximizeRevenue\" the goal of Maximizing\r\n", | |
"// the Objective Expression\r\n", | |
"let objective = Objective.create \"MaximizeRevenue\" Maximize objectiveExpression\r\n", | |
"\r\n", | |
"// Create a Constraint for the max number of Hamburger considering the number of buns\r\n", | |
"let maxHamburger = Constraint.create \"MaxHamburger\" (numberOfHamburgers <== hamburgerBuns)\r\n", | |
"// Create a Constraint for the max number of Hot Dogs considering the number of buns\r\n", | |
"let maxHotDog = Constraint.create \"MaxHotDog\" (numberOfHotdogs <== hotdogBuns)\r\n", | |
"// Create a Constraint for the Max combined weight of Hamburgers and Hotdogs\r\n", | |
"let maxWeight = Constraint.create \"MaxWeight\" (numberOfHotdogs * hotdogWeight + numberOfHamburgers * hamburgerWeight <== maxTruckWeight)\r\n", | |
"\r\n", | |
"// Create a Model type and pipe it through the addition of the constraitns\r\n", | |
"let model =\r\n", | |
" Model.create objective\r\n", | |
" |> Model.addConstraint maxHamburger\r\n", | |
" |> Model.addConstraint maxHotDog\r\n", | |
" |> Model.addConstraint maxWeight\r\n", | |
"\r\n", | |
"// Create a Settings type which tells the Solver which types of underlying solver to use,\r\n", | |
"// the time alloted for solving, and whether to write an LP file to disk\r\n", | |
"let settings = {\r\n", | |
" SolverType = SolverType.CBC\r\n", | |
" MaxDuration = 10_000L\r\n", | |
" WriteLPFile = None\r\n", | |
"}\r\n", | |
"\r\n", | |
"// Call the `solve` function in the Solve module to evaluate the model\r\n", | |
"let result = solve settings model\r\n", | |
"\r\n", | |
"result" | |
], | |
"outputs": [] | |
}, | |
{ | |
"cell_type": "markdown", | |
"metadata": {}, | |
"source": "And also packages where the order in which you reference `.dll`s matters." | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 1, | |
"metadata": {}, | |
"source": [ | |
"#!fsharp\r\n", | |
"#r \"nuget: FParsec\"\r\n", | |
"\r\n", | |
"open FParsec\r\n", | |
"\r\n", | |
"let test p str =\r\n", | |
" match run p str with\r\n", | |
" | Success(result, _, _) -> printfn \"Success: %A\" result\r\n", | |
" | Failure(errorMsg, _, _) -> printfn \"Failure: %s\" errorMsg\r\n", | |
"\r\n", | |
"test pfloat \"1.234\"" | |
], | |
"outputs": [] | |
}, | |
{ | |
"cell_type": "markdown", | |
"metadata": {}, | |
"source": "## Better interop with Nullable Value Types\r\n\r\nInterop with Nullable Value Types has typically been a pain in F#. You always have to explicitly construct a `Nullable` type when passing something like `3` into a method that requires a `Nullable<int>`.\r\n\r\nIn F# 5, this conversion is handled for you." | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 1, | |
"metadata": {}, | |
"source": [ | |
"#!fsharp\r\n", | |
"#r \"nuget: Microsoft.Data.Analysis\"\r\n", | |
"open Microsoft.Data.Analysis\r\n", | |
"\r\n", | |
"let dateTimes = PrimitiveDataFrameColumn<DateTime>(\"DateTimes\")\r\n", | |
"\r\n", | |
"// The DateTimes added are actually Nullable<DateTime>\r\n", | |
"\r\n", | |
"// You can just pass in a DateTime\r\n", | |
"dateTimes.Append(DateTime.Parse(\"2019/01/01\"))\r\n", | |
"\r\n", | |
"// Prior to F# 5, you needed to do this:\r\n", | |
"dateTimes.Append(Nullable<DateTime>(DateTime.Parse(\"2019/01/01\")))" | |
], | |
"outputs": [] | |
}, | |
{ | |
"cell_type": "markdown", | |
"metadata": {}, | |
"source": "## Slices and indexes" | |
}, | |
{ | |
"cell_type": "markdown", | |
"metadata": {}, | |
"source": "### Tolerant and consistent slices\n\nBefore, a built-in F# collection type may (or may not) throw if you slice outside its bounds.\n\nNow, it does not throw." | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 1, | |
"metadata": {}, | |
"source": [ | |
"#!fsharp\r\n", | |
"let l = [ 1..10 ]\r\n", | |
"let a = [| 1..10 |]\r\n", | |
"let s = \"hello!\"" | |
], | |
"outputs": [] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 1, | |
"metadata": {}, | |
"source": [ | |
"#!fsharp\r\n", | |
"a.[0]" | |
], | |
"outputs": [] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 1, | |
"metadata": {}, | |
"source": [ | |
"#!fsharp\r\n", | |
"// Before: would not throw throw exception\r\n", | |
"// F# 5: same\r\n", | |
"let lCount = l.[-2..(-1)].Length\r\n", | |
"\r\n", | |
"// Before: would throw exception\r\n", | |
"// F# 5: does not throw exception\r\n", | |
"let aCount = a.[-2..(-1)].Length\r\n", | |
"\r\n", | |
"// Before: would throw exception\r\n", | |
"// F# 5: does not throw exception\r\n", | |
"let sCount = s.[-2..(-1)].Length\r\n", | |
"\r\n", | |
"{| ``lCount`` = lCount; ``aCount`` = aCount; ``sCount`` = sCount |}" | |
], | |
"outputs": [] | |
}, | |
{ | |
"cell_type": "markdown", | |
"metadata": {}, | |
"source": "### 3D and 4D array slices\n\nIf we have the following 3D array,\n\n*z = 0*\n\nx\\y | 0 | 1 |\n----|----|---\n0 | 0 | 1\n1 | 2 | 3\n\n*z = 1*\n\nx\\y | 0 | 1 |\n----|----|---\n0 | 4 | 5\n1 | 6 | 7\n\nWhat if we wanted to extract the slice `[| 4; 5 |]` from the array? This is now possible!" | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 1, | |
"metadata": {}, | |
"source": [ | |
"#!fsharp\r\n", | |
"// First, create a 3D array to slice\r\n", | |
"\r\n", | |
"let dim = 2\r\n", | |
"let m = Array3D.zeroCreate<int> dim dim dim\r\n", | |
"\r\n", | |
"let mutable cnt = 0\r\n", | |
"\r\n", | |
"for z in 0..dim-1 do\r\n", | |
" for y in 0..dim-1 do\r\n", | |
" for x in 0..dim-1 do\r\n", | |
" m.[x,y,z] <- cnt\r\n", | |
" cnt <- cnt + 1" | |
], | |
"outputs": [] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 1, | |
"metadata": {}, | |
"source": [ | |
"#!fsharp\r\n", | |
"// Now let's get the [4;5] slice!\r\n", | |
"\r\n", | |
"m.[*, 0, 1]" | |
], | |
"outputs": [] | |
}, | |
{ | |
"cell_type": "markdown", | |
"metadata": {}, | |
"source": "### \"From the end\" slices and indexes" | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 1, | |
"metadata": {}, | |
"source": [ | |
"#!fsharp\r\n", | |
"// \"From the end\" indexes\r\n", | |
"\r\n", | |
"let xs = [1..10]\r\n", | |
"\r\n", | |
"// Get element 1 from the end:\r\n", | |
"xs.[^1]" | |
], | |
"outputs": [] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 1, | |
"metadata": {}, | |
"source": [ | |
"#!fsharp\r\n", | |
"// From the end slices\r\n", | |
"\r\n", | |
"let lastTwoOldStyle = xs.[(xs.Length-2)..]\r\n", | |
"\r\n", | |
"let lastTwoNewStyle = xs.[^1..]\r\n", | |
"\r\n", | |
"lastTwoOldStyle, lastTwoNewStyle" | |
], | |
"outputs": [] | |
}, | |
{ | |
"cell_type": "markdown", | |
"metadata": {}, | |
"source": "You can define your own slices with the `GetReverseIndex: dimension: int -> offset: int` member on a type. Here, we extend the `Span<'T>` type to support slices and reverse indexes, allowing us to do all forms of slicing and indexing:" | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 1, | |
"metadata": {}, | |
"source": [ | |
"#!fsharp\r\n", | |
"open System\r\n", | |
"\r\n", | |
"type Span<'T> with\r\n", | |
" // Note the 'inline' in the member definition\r\n", | |
" member inline sp.GetSlice(startIdx, endIdx) =\r\n", | |
" let s = defaultArg startIdx 0\r\n", | |
" let e = defaultArg endIdx sp.Length\r\n", | |
" sp.Slice(s, e - s)\r\n", | |
" \r\n", | |
" member inline sp.GetReverseIndex(_, offset: int) =\r\n", | |
" sp.Length - offset\r\n", | |
"\r\n", | |
"let printSpan (sp: Span<int>) =\r\n", | |
" let arr = sp.ToArray()\r\n", | |
" printfn \"%A\" arr\r\n", | |
"\r\n", | |
"let run () =\r\n", | |
" let sp = [| 1; 2; 3; 4; 5 |].AsSpan()\r\n", | |
" \r\n", | |
" // Pre-# 5.0 slicing on a Span<'T>\r\n", | |
" printSpan sp.[0..] // [|1; 2; 3; 4; 5|]\r\n", | |
" printSpan sp.[..3] // [|1; 2; 3|]\r\n", | |
" printSpan sp.[1..3] // |2; 3|]\r\n", | |
" \r\n", | |
" // Same slices, but only using from-the-end index\r\n", | |
" printSpan sp.[..^0]\r\n", | |
" printSpan sp.[..^2]\r\n", | |
" printSpan sp.[^4..^2]\r\n", | |
"\r\n", | |
"run()" | |
], | |
"outputs": [] | |
}, | |
{ | |
"cell_type": "markdown", | |
"metadata": {}, | |
"source": "## The `nameof` function\n\nIf you've used C#, you've likely found use in `nameof` when doing things like logging or validating parameters to functions. It's now available as an intrinsic F# function." | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 1, | |
"metadata": {}, | |
"source": [ | |
"#!fsharp\r\n", | |
"let months =\r\n", | |
" [\r\n", | |
" \"January\"; \"February\"; \"March\"; \"April\";\r\n", | |
" \"May\"; \"June\"; \"July\"; \"August\"; \"September\";\r\n", | |
" \"October\"; \"November\"; \"December\"\r\n", | |
" ]\r\n", | |
"\r\n", | |
"let lookupMonth month =\r\n", | |
" if (month > 12 || month < 1) then\r\n", | |
" invalidArg (nameof month) (sprintf \"Value passed in was %d.\" month)\r\n", | |
"\r\n", | |
" months.[month-1]\r\n", | |
"\r\n", | |
"printfn \"%s\" (lookupMonth 12)\r\n", | |
"printfn \"%s\" (lookupMonth 1)\r\n", | |
"printfn \"%s\" (lookupMonth 13)" | |
], | |
"outputs": [] | |
}, | |
{ | |
"cell_type": "markdown", | |
"metadata": {}, | |
"source": "You can take a name of almost everything in F#:\n\n* Parameters\n* Functions\n* Classes\n* Modules\n* Namespaces\n\nThere are some current restrictions on overloaded methods and type parameters that we are planning on addressing." | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 1, | |
"metadata": {}, | |
"source": [ | |
"#!fsharp\r\n", | |
"module M =\r\n", | |
" let f x = nameof x\r\n", | |
"\r\n", | |
"printfn \"%s\" (M.f 12)\r\n", | |
"printfn \"%s\" (nameof M)\r\n", | |
"printfn \"%s\" (nameof M.f)" | |
], | |
"outputs": [] | |
}, | |
{ | |
"cell_type": "markdown", | |
"metadata": {}, | |
"source": "## Opening static classes\n\nWe're introducing the ability to \"open\" a static class as if it were a module or namespace. This applies to any static class in .NET (or any package), or your own F#-defined static class." | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 1, | |
"metadata": {}, | |
"source": [ | |
"#!fsharp\r\n", | |
"// Opening the 'Math' static class from .NET\r\n", | |
"\r\n", | |
"open System.Math\r\n", | |
"\r\n", | |
"PI" | |
], | |
"outputs": [] | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 1, | |
"metadata": {}, | |
"source": [ | |
"#!fsharp\r\n", | |
"// Defining our own static class and opening it\r\n", | |
"\r\n", | |
"[<AbstractClass;Sealed>]\r\n", | |
"type A =\r\n", | |
" static member Add(x, y) = x + y\r\n", | |
" \r\n", | |
"open A\r\n", | |
"\r\n", | |
"Add(2.0, PI)" | |
], | |
"outputs": [] | |
}, | |
{ | |
"cell_type": "markdown", | |
"metadata": {}, | |
"source": "## Applicative computation expressions\n\nComputation expressions are used today to model \"contextual computations\", or in more FP-friendly terminology, monadic computations. However, they are a more flexible construct than just offering syntax for monads.\n\nF# 5 introduces applicative computations, which allow for significantly more efficient computations provided that every computation is independent, and their results are merely accumulated at the end. This benefit comes at a restriction: values that depend on previously-computed values are not valid, since that would not be an applicative form of computation expression.\n\nThe follow examples show a basic applicative CE for the `Result` type." | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 1, | |
"metadata": {}, | |
"source": [ | |
"#!fsharp\r\n", | |
"// First, define a 'zip' function\r\n", | |
"module Result =\r\n", | |
" let zip x1 x2 = \r\n", | |
" match x1,x2 with\r\n", | |
" | Ok x1res, Ok x2res -> Ok (x1res, x2res)\r\n", | |
" | Error e, _ -> Error e\r\n", | |
" | _, Error e -> Error e\r\n", | |
"\r\n", | |
"// Next, define a builder with 'MergeSources' and 'BindReturn'\r\n", | |
"type ResultBuilder() = \r\n", | |
" member _.MergeSources(t1: Result<'T,'U>, t2: Result<'T1,'U>) = Result.zip t1 t2\r\n", | |
" member _.BindReturn(x: Result<'T,'U>, f) = Result.map f x\r\n", | |
"\r\n", | |
"let result = ResultBuilder()" | |
], | |
"outputs": [] | |
}, | |
{ | |
"cell_type": "markdown", | |
"metadata": {}, | |
"source": "Now we can use the result CE that we just built!" | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 1, | |
"metadata": {}, | |
"source": [ | |
"#!fsharp\r\n", | |
"let resultValue1 = Ok 2\r\n", | |
"let resultValue2 = Ok 3 // Error \"fail!\"\r\n", | |
"let resultValue3 = Ok 4\r\n", | |
" \r\n", | |
"let res1: Result<int, string> =\r\n", | |
" result { \r\n", | |
" let! a = resultValue1 \r\n", | |
" and! b = resultValue2\r\n", | |
" and! c = resultValue3\r\n", | |
" return a + b - c \r\n", | |
" }\r\n", | |
"\r\n", | |
"match res1 with\r\n", | |
"| Ok x -> printfn \"%s is: %d\" (nameof res1) x\r\n", | |
"| Error e -> printfn \"%s is %s\" (nameof res1) e" | |
], | |
"outputs": [] | |
}, | |
{ | |
"cell_type": "markdown", | |
"metadata": {}, | |
"source": "And as you'd expect, if one of the values is an `Error`, then there is no value:" | |
}, | |
{ | |
"cell_type": "code", | |
"execution_count": 1, | |
"metadata": {}, | |
"source": [ | |
"#!fsharp\r\n", | |
"let resultValue1 = Ok 2\r\n", | |
"let resultValue2 = Error \"fail!\"\r\n", | |
"let resultValue3 = Ok 4\r\n", | |
" \r\n", | |
"let res1: Result<int, string> =\r\n", | |
" result { \r\n", | |
" let! a = resultValue1 \r\n", | |
" and! b = resultValue2\r\n", | |
" and! c = resultValue3\r\n", | |
" return a + b - c \r\n", | |
" }\r\n", | |
"\r\n", | |
"match res1 with\r\n", | |
"| Ok x -> printfn \"%s is: %d\" (nameof res1) x\r\n", | |
"| Error e -> printfn \"%s is %s\" (nameof res1) e" | |
], | |
"outputs": [] | |
}, | |
{ | |
"cell_type": "markdown", | |
"metadata": {}, | |
"source": "And that's it! (for now...)" | |
}, | |
{ | |
"cell_type": "markdown", | |
"metadata": {}, | |
"source": "## The road ahead for F# 5\n\nDespite a number of features being available today, we're still very much in active development for F# 5. When new features are ready, we'll release them in the next available .NET 5 preview.\n\nBecause previews are released so that we can get feedback from users, we might make breaking changes from one preview to the next to accomodate feedback we feel deserves a design change. We might also decide to keep a feature in preview for the F# 5 GA release if there is enough feedback that the design isn't quite right. We encourage you to try these features out and let us know what you feel needs improvement!\n\nCheers, and happy hacking!" | |
} | |
], | |
"metadata": { | |
"kernelspec": { | |
"display_name": ".NET (C#)", | |
"language": "C#", | |
"name": ".net-csharp" | |
}, | |
"language_info": { | |
"file_extension": ".cs", | |
"mimetype": "text/x-csharp", | |
"name": "C#", | |
"pygments_lexer": "csharp", | |
"version": "8.0" | |
} | |
}, | |
"nbformat": 4, | |
"nbformat_minor": 4 | |
} |
Thanks!
Before, a built-in F# collection type may (or may not) through if you slice outside its bounds.
Should probably be throw.
Features (so far):
- Tolerant and consistent for lists, arrays, strings, 3D arrays, and 4d arrays
I suspect the word "slices" is missing there (so it should be: "Tolerant and consistent slices for lists, arrays, strings, 3D arrays, and 4d arrays").
Why is this a jupyter notebook? Noone uses it for F#. Can't it be markdown?
#r
is great though
Great news! Can I try this by building fsharp from master on https://github.com/dotnet/fsharp ? or another branch?
Any performance improvements in the preview to look at?
Why is this a jupyter notebook? Noone uses it for F#. Can't it be markdown?
I like using Jupyter for F#. Its cells allow for easily running a section of code vs F# scripts, where the options are selecting the code you want to run, or running the entire file.
Here's the youtube video of Phillip demoing it: https://www.youtube.com/watch?v=kKH8NzvtENQ&t=3057s
@goswinr yes, but Binder will be the easiest.
@spillsthrills There are performance improvements, but they're orthogonal to F# 5. We routinely do performance work, with a good set corresponding with the VS 16.5 release and another corresponding with the VS 16.6 release.
There is a typo here:
Features (so far):
Tolerant and consistent for lists, arrays, strings, 3D arrays, and 4d arrays
it probably should say:
Tolerant and consistent slices for lists, arrays, strings, 3D arrays, and 4d arrays
These are fantastic improvements!
@amieres Thanks!
#r "nuget"
is definitely the one I'm looking forward to.
Without trying to diminish the work done by everyone involved, I'm wondering though are there more significant changes coming in v5.0?
The changes presented here don't feel like justifying the major version bump. It's more like incremental improvements similar to v4.5, 4.6, 4.7, which were very welcome of course (I love the anonymous records).
@anilmujagic Part of the challenge here is that no two developers share the same definition of what constitutes a major version bump. In addition to this, we're making FSharp.Core a .NET Standard 2.0-only component moving forward, and all new project templates in Visual Studio will be .NET SDK-style only (.NET Framework still supported, but no legacy project files). We're also planning on at least two more language features for F# 5. This is quite a lot more change than any of the 4.5/4.6/4.7 versions, hence the major version bump.
Part of the challenge here is that no two developers share the same definition of what constitutes a major version bump.
I was secretly hoping that major version will bring Typeclasses :)
@SLAVONchick we're planning on doing a #I "nuget:source"
directive to specify that sort of stuff. probably by the next preview.
@cartermp Cool! Thanks!
@cartermp Hello! I've read today about tooling improvements in VS 2019 16.9 Update and I was wondering, is it possible now to specify my own source while using #r "nuget: ..."
? Thanks!
Yes, this is documented here: https://docs.microsoft.com/en-us/dotnet/fsharp/tools/fsharp-interactive/#specifying-a-package-source
@cartermp Thank you! That’s great!
You have a typo: should be 'found' instead of 'foun'
Other than that, thanks for the share! Your Jupyter NB is great :D
P.S I really like that we can finally reference a NuGet package from the interactive!