TL;DR: How do I get the actual property value from a quotation of the form
<@ myInstance.myProperty @>
I'm trying to simplify INotifyPropertyChanged using F#. Instead of subscribing directly to PropertyChanged, I want to use a method that takes a code quotation containing the property I want to subscribe to (e.g. <@ vm.IsChanged @>
) and a callback (or alternatively just the quotation and returns an observable of the relevant property). For example:
type MyVm() =
inherit INPCBaseWithObserveMethod()
...
let vm = new MyVm()
vm.Observe <@ vm.IsChanged @> (fun isChanged -> ...)
I'm new to code quotations and I'm struggling with the implementation of the Observe
method. I know how to get the property name from this kind of expression, but not the value. Here's what I have so far (note the placeholder in propInfo.GetValue
):
type ViewModelBase() =
// Start INPC boilerplate
let propertyChanged = new Event<_, _>()
interface INotifyPropertyChanged with
[<CLIEvent>]
member __.PropertyChanged = propertyChanged.Publish
member this.OnPropertyChanged(propertyName : string) =
propertyChanged.Trigger(this, new PropertyChangedEventArgs(propertyName))
// End INPC boilerplate
member this.Observe (query: Expr<'a>) (callback: 'a -> unit) : unit =
match query with
| PropertyGet(instanceExpr, propInfo, _) ->
(this :> INotifyPropertyChanged).PropertyChanged
|> Observable.filter (fun args -> args.PropertyName = propInfo.Name)
|> Observable.map (fun _ -> propInfo.GetValue(TODO) :?> 'a)
|> Observable.add callback
| _ -> failwith "Expression must be a non-static property getter"
I figured it out based on some experimentation and this quotation eval function. In the most simple case (when vm
in <@ vm.MyProperty @>
is a local let
-bound value), the instance expression will match the Value
pattern:
| PropertyGet(Some (Value (instance, _)), propInfo, [])
instance
can then be passed to PropertyInfo.GetValue
. However, if vm
is a field (class-level let
binding) or anything else, then the pattern will be different (e.g. containing a nested FieldGet
which will need to be evaluated to get the correct instance you can pass to PropertyInfo.GetValue
).
In short, it seems the best course of action is simply using the eval
function I linked to. The whole ViewModelBase
class then becomes (see this snippet for a more complete implementation):
type ViewModelBase() =
/// Evaluates an expression. From http://www.fssnip.net/h1
let rec eval = function
| Value (v, _) -> v
| Coerce (e, _) -> eval e
| NewObject (ci, args) -> ci.Invoke (evalAll args)
| NewArray (t, args) ->
let array = Array.CreateInstance (t, args.Length)
args |> List.iteri (fun i arg -> array.SetValue (eval arg, i))
box array
| NewUnionCase (case, args) -> FSharpValue.MakeUnion (case, evalAll args)
| NewRecord (t, args) -> FSharpValue.MakeRecord (t, evalAll args)
| NewTuple args ->
let t = FSharpType.MakeTupleType [| for arg in args -> arg.Type |]
FSharpValue.MakeTuple (evalAll args, t)
| FieldGet (Some (Value (v, _)), fi) -> fi.GetValue v
| PropertyGet (None, pi, args) -> pi.GetValue (null, evalAll args)
| PropertyGet (Some x, pi, args) -> pi.GetValue (eval x, evalAll args)
| Call (None, mi, args) -> mi.Invoke (null, evalAll args)
| Call (Some x, mi, args) -> mi.Invoke (eval x, evalAll args)
| x -> raise <| NotSupportedException(string x)
and evalAll args = [| for arg in args -> eval arg |]
let propertyChanged = new Event<_,_>()
interface INotifyPropertyChanged with
[<CLIEvent>]
member __.PropertyChanged = propertyChanged.Publish
member this.OnPropertyChanged(propertyName : string) =
propertyChanged.Trigger(this, new PropertyChangedEventArgs(propertyName))
/// Given a property-getter quotation, calls the callback with the value of
/// the expression every time INotifyPropertyChanged is raised for this property.
member this.Observe (expr: Expr<'a>) (callback: 'a -> unit) : unit =
match expr with
| PropertyGet (_, propInfo, _) ->
(this :> INotifyPropertyChanged).PropertyChanged
|> Observable.filter (fun args -> args.PropertyName = propInfo.Name)
|> Observable.map (fun _ -> eval expr :?> 'a)
|> Observable.add callback
| _ -> failwith "Expression must be a property getter"
Observe
can of course be trivially modified to return an observable instead of subscribing directly.
Note that in many scenarios Observable.DistinctUntilChanged
might be desired after Observable.map
. (I use Observe
to, among other things, trigger animations from view model properties, and due assumptions in my particular animation code, the animations got all wonky when there were several subsequent calls to the callback with an unchanged property value.)