Earlier I requested some feedback on my first F# project. Before closing the question because the scope was too large, someone was kind enough to look it over and leave some feedback.
One of the things they mentioned was pointing out that I had a number of regular functions that could be converted to be methods on my datatypes. Dutifully I went through changing things like
let getDecisions hand =
let (/=/) card1 card2 = matchValue card1 = matchValue card2
let canSplit() =
let isPair() =
match hand.Cards with
| card1 :: card2 :: [] when card1 /=/ card2 -> true
| _ -> false
not (hasState Splitting hand) && isPair()
let decisions = [Hit; Stand]
let split = if canSplit() then [Split] else []
let doubleDown = if hasState Initial hand then [DoubleDown] else []
decisions @ split @ doubleDown
to this:
type Hand
// ...stuff...
member hand.GetDecisions =
let (/=/) (c1 : Card) (c2 : Card) = c1.MatchValue = c2.MatchValue
let canSplit() =
let isPair() =
match hand.Cards with
| card1 :: card2 :: [] when card1 /=/ card2 -> true
| _ -> false
not (hand.HasState Splitting) && isPair()
let decisions = [Hit; Stand]
let split = if canSplit() then [Split] else []
let doubleDown = if hand.HasState Initial then [DoubleDown] else []
decisions @ split @ doubleDown
Now, I don't doubt I'm an idiot, but other than (I'm guessing) making C# interop easier, what did that gain me? Specifically, I found a couple disadvantages, not counting the extra work of conversion (which I won't count, since I could have done it this way in the first place, I suppose, although that would have made using F# Interactive more of a pain). For one thing, I'm now no longer able to work with function "pipelining" easily. I had to go and change some |> chained |> calls
to (some |> chained).Calls
etc. Also, it seemed to make my type system dumber--whereas with my original version, my program needed no type annotations, after converting largely to member methods, I got a bunch of errors about lookups being indeterminate at that point, and I had to go and add type annotations (an example of this is in the (/=/)
above).
I hope I haven't come off too dubious, as I appreciate the advice I received, and writing idiomatic code is important to me. I'm just curious why the idiom is the way it is :)
Thanks!
One practice I used in my F# programming is use the code in both places.
The actual implementation is natural to be put in a module:
module Hand =
let getDecisions hand =
let (/=/) card1 card2 = matchValue card1 = matchValue card2
...
and give a 'link' in the member function/property:
type Hand
member this.GetDecisions = Hand.getDecisions this
This practice is also generally used in other F# libraries, e.g. the matrix and vector implementation matrix.fs in powerpack.
In practice, not every function should be put in both places. The final decision should be based on the domain knowledge.
Regarding the top-level, the practice is to put functions in to modules, and make some of them in the top-level if necessary. E.g. in F# PowerPack, Matrix<'T>
is in the namespace Microsoft.FSharp.Math
. It is convenient to make a shortcut for the matrix constructor in the toplevel so that the user can construct a matrix directly without opening the namespace:
[<AutoOpen>]
module MatrixTopLevelOperators =
let matrix ll = Microsoft.FSharp.Math.Matrix.ofSeq ll
An advantage of members is Intellisense and other tooling that makes members discoverable. When a user wants to explore an object foo
, they can type foo.
and get a list of the methods on the type. Members also 'scale' easier, in the sense that you don't end up with dozens of names floating around at the top level; as program size grows, you need more names to only be available when qualified (someObj.Method or SomeNamespace.Type or SomeModule.func, rather than just Method/Type/func 'floating free').
As you've seen, there are disadvantages as well; type inference is especially notable (you need to know the type of x
a priori to call x.Something
); in the case of types and functionality that is used very commonly, it may be useful to provide both members and a module of functions, to have the benefits of both (this is e.g. what happens for common data types in FSharp.Core).
These are typical trade-offs of "scripting convenience" versus "software engineering scale". Personally I always lean towards the latter.
This is a tricky question and both of the approaches have advantages and disadvantages. In general, I tend to use members when writing more object-oriented/C# style code and global functions when writing more functional code. However, I'm not sure I can clearly state what the difference is.
I would prefer using global functions when writing:
Functional data types such as trees/lists and other generally useable data structures that keep some larger amount of data in your program. If your type provides some operations that are expressed as higher order functions (e.g. List.map
) then it is probably this kind of data type.
Not type-related functionality - there are situations when some operations don't strictly belong to one particular type. For example, when you have two representations of some data structure and you're implementing transformation between the two (e.g. typed AST and untyped AST in a compiler). In that case, functions are better choice.
On the other hand, I would use members when writing:
Simple types such as Card
that can have properties (like value and color) and relatively simple (or no) methods performing some computations.
Object-oriented - when you need to use object-oriented concepts, such as interfaces, then you'll need to write the functionality as members, so it may be useful to consider (in advance) whether this will be the case for your types.
I would also mention that it is perfectly fine to use members for some simple types in your application (e.g. Card
) and implement the rest of the application using top-level functions. In fact, I think that this would probably be the best approach in your situation.
Notably, it is also possible to create a type that has members together with a module that provides the same functionality as functions. This allows the user of the library to choose the style he/she prefers and this is completely valid approach (however, it probably makes sense for stand-alone libraries).
As a side-note, chaining of calls can be achieved using both members and functions:
// Using LINQ-like extension methods
list.Where(fun a -> a%3 = 0).Select(fun a -> a * a)
// Using F# list-processing functions
list |> List.filter (fun a -> a%3 = 0) |> List.map (fun a -> a * a)
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With