This is my attempt to write a data-bindable, platform independent property that doesn't require any literal strings. The basic idea is to basically have the same functionality as IObservable, but with a value which has a getter (and a setter in a derived interface). The core interface only has a getter:
public interface IPropertySource<out T>
{
T Value { get; }
IDisposable RawSubscribe(Action rawObserver);
}
Since it has a getter, the Action
in RawSubscribe
doesn't need to receive a value. This can be turned into a monad and add Linq operators with some extra code (I've removed all the null checks to make it shorter) (note that this code requires a reference to reactive extensions):
internal class ExplicitPropertySource<T> : IPropertySource<T>
{
private readonly Func<T> _GetValue;
private readonly Func<Action, IDisposable> _RawSubscribe;
public T Value
{
get { return _GetValue(); }
}
public ExplicitPropertySource(Func<Action, IDisposable> rawSubscribe, Func<T> getValue)
{
_RawSubscribe = rawSubscribe;
_GetValue = getValue;
}
public IDisposable RawSubscribe(Action rawObserver)
{
return _RawSubscribe(rawObserver);
}
}
public static partial class PropertySource
{
public static IPropertySource<T> Create<T>(Func<Action, IDisposable> rawSubscribe, Func<T> getValue)
{
return new ExplicitPropertySource<T>(rawSubscribe, getValue);
}
public static IPropertySource<T> Return<T>(T value)
{
return PropertySource.Create(observer => Disposable.Empty, () => value);
}
public static IPropertySource<T> Distinct<T>(this IPropertySource<T> source, IEqualityComparer<T> comparer)
{
return PropertySource.Create(
action =>
{
T cachedValue = default(T);
Action<T> sendAndCache = value =>
{
action();
cachedValue = value;
};
Action sendIfChanged = () =>
{
var value = source.Value;
if (!comparer.Equals(value, cachedValue))
sendAndCache(value);
};
Action observer = null;
observer = () =>
{
observer = sendIfChanged;
sendAndCache(source.Value);
};
return source.RawSubscribe(() =>
{
observer();
});
},
() => source.Value
);
}
public static IPropertySource<T> Distinct<T>(this IPropertySource<T> source)
{
return source.Distinct(EqualityComparer<T>.Default);
}
public static IPropertySource<T> Eager<T>(this IPropertySource<T> source)
{
return PropertySource.Create(
action =>
{
action();
return source.RawSubscribe(action);
},
() => source.Value
);
}
public static IPropertySource<T> Lazy<T>(this IPropertySource<T> source)
{
return PropertySource.Create(
action =>
{
Action send = () => { };
var subscription = source.RawSubscribe(() => send());
send = action;
return subscription;
},
() => source.Value
);
}
public static IPropertySource<TResult> SelectMany<TSource, TResult>(this IPropertySource<TSource> source, Func<TSource, IPropertySource<TResult>> selector)
{
return PropertySource.Create(
observer =>
{
IDisposable rightSubscription = Disposable.Empty;
Func<TSource, IPropertySource<TResult>> reattachRight = leftValue =>
{
rightSubscription.Dispose();
var rightSource = selector(leftValue);
rightSubscription = rightSource.Lazy().RawSubscribe(observer);
return rightSource;
};
IDisposable leftSubscription = source.Lazy().RawSubscribe(() =>
{
reattachRight(source.Value);
observer();
});
reattachRight(source.Value);
return Disposable.Create(() =>
{
leftSubscription.Dispose();
rightSubscription.Dispose();
});
},
() => selector(source.Value).Value
);
}
public static IPropertySource<TResult> Select<TSource, TResult>(this IPropertySource<TSource> source, Func<TSource, TResult> selector)
{
return source.SelectMany(value => PropertySource.Return(selector(value)));
}
public static IPropertySource<TResult> Merge<TLeft, TRight, TResult>(this IPropertySource<TLeft> left, IPropertySource<TRight> right, Func<TLeft, TRight, TResult> selector)
{
return left.SelectMany(leftValue =>
right.Select(rightValue => selector(leftValue, rightValue))
);
}
}
Since I'm using WinForms, I can then create these using standard WinForms properties:
public class MemberAccessInfo
{
public readonly object Instance;
public readonly string MemberName;
public MemberAccessInfo(object instance, string memberName)
{
Instance = instance;
MemberName = memberName;
}
}
public static partial class PropertySource
{
private static MemberAccessInfo GetMemberAccessInfo<T>(Expression<Func<T>> memberAccessExpression)
{
LambdaExpression lambda = (LambdaExpression)memberAccessExpression;
if (lambda.Body.NodeType != ExpressionType.MemberAccess)
throw new Exception("Expression must be a member access.");
MemberExpression memberExpr = (MemberExpression)lambda.Body;
var instance = Expression.Lambda(memberExpr.Expression).Compile().DynamicInvoke();
return new MemberAccessInfo(instance, memberExpr.Member.Name);
}
internal static IPropertySource<T> FromProperty<T>(object instance, Type instanceType, PropertyInfo memberInfo)
{
var eventInfo = instanceType.GetEvent(memberInfo.Name + "Changed", BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public);
Func<Action, IDisposable> subscribe;
if (eventInfo != null && eventInfo.EventHandlerType == typeof(EventHandler))
{
subscribe = observer =>
{
EventHandler handler = (s, e) => observer();
eventInfo.AddEventHandler(instance, handler);
return Disposable.Create(() => eventInfo.RemoveEventHandler(instance, handler));
};
}
else
{
var notifyPropertyChanged = instance as INotifyPropertyChanged;
if (notifyPropertyChanged == null)
throw new Exception("This member cannot be observed.");
subscribe = observer =>
{
PropertyChangedEventHandler handler = (s, e) =>
{
if (e.PropertyName == memberInfo.Name)
observer();
};
notifyPropertyChanged.PropertyChanged += handler;
return Disposable.Create(() => notifyPropertyChanged.PropertyChanged -= handler);
};
}
return Create(subscribe, () => (T)memberInfo.GetValue(instance));
}
public static IPropertySource<T> FromProperty<T>(object instance, string propertyName)
{
var type = instance.GetType();
return FromProperty<T>(instance, type, type.GetProperty(propertyName));
}
public static IPropertySource<T> FromProperty<T>(Expression<Func<T>> memberAccessExpression)
{
var propertyInfo = GetMemberAccessInfo(memberAccessExpression);
return FromProperty<T>(propertyInfo.Instance, propertyInfo.MemberName);
}
}
And finally, subscribe with these:
public static partial class PropertySource
{
public static IDisposable Subscribe<T>(this IPropertySource<T> source, Action<T> observer, IEqualityComparer<T> comparer)
{
return source.Eager().Distinct(comparer).RawSubscribe(() => observer(source.Value));
}
public static IDisposable Subscribe<T>(this IPropertySource<T> source, Action<T> observer)
{
return source.Eager().Distinct().RawSubscribe(() => observer(source.Value));
}
}
So I can do stuff like:
var firstNameProperty = PropertySource.FromProperty(() => firstNameTextBox.Text);
var lastNameProperty = PropertySource.FromProperty(() => lastNameTextBox.Text);
var fullNameProperty = firstNameProperty.Merge(lastNameProperty, (fn, ln) => fn + " " + ln);
var disposable = fullNameProperty.Subscribe(val => fullNameLabel.Text = val);
This seems to me like such an obvious missing feature I was wondering if something like this already exists, and if not, whether I should make any improvements to my code. Also, I'm still not 100% my monad is actually a monad, but so far it's been working fine.
This whole thing probably doesn't make much sense without the IProperty
interface which also has a property setter, but is not a monad (it does have a SelectMany
and Select
methods though), and can also be created given a WinForms property. I'm not including it here since it's already pretty big, but I uploaded the whole thing to GitHib, and included a small sample WinForms application.
1 Answer 1
T cachedValue = default(T);
Action<T> sendAndCache = value =>
{
action();
cachedValue = value;
};
Action sendIfChanged = () =>
{
var value = source.Value;
if (!comparer.Equals(value, cachedValue))
sendAndCache(value);
};
Action observer = null;
observer = () =>
{
observer = sendIfChanged;
sendAndCache(source.Value);
};
return source.RawSubscribe(() =>
{
observer();
});
Here, you create a special initial observer
which always sends the value (since there is nothing to compare it to) and then replace it with a normal comparing observer. I think it would be simpler and clearer to have a single observer
, along with a variable tracking whether the cachedValue
has been set:
T cachedValue = default(T);
bool cachedValueSet = false;
Action observer = () =>
{
var value = source.Value;
if (!cachedValueSet || !comparer.Equals(value, cachedValue))
{
action();
cachedValue = value;
cachedValueSet = true;
}
};
return source.RawSubscribe(observer);
() => selector(source.Value).Value
What happens if source.Value
is null
?
I'm also wondering about the monad thing: does using LINQ queries on your objects make sense? If not, I probably wouldn't use names like Select
or SelectMany
.
-
\$\begingroup\$ I've modified the Distinct method a bit. Here's the new version. Regarding the null, it'll just throw. This is the same behavior as
Enumerable
'sSelectMany
, so I guess that'd be the expected thing to happen. \$\endgroup\$Juan– Juan2016年07月13日 19:13:46 +00:00Commented Jul 13, 2016 at 19:13 -
\$\begingroup\$ @Juan I don't think it is, I would expect binding to
A.B
to not throw, even whenA
is null. Also an enumerable can be empty, to me,null
property seems to be a natural equivalent to that. \$\endgroup\$svick– svick2016年07月13日 19:21:11 +00:00Commented Jul 13, 2016 at 19:21 -
\$\begingroup\$ I misunderstood you, I thought you meant when the result of selector is null. If
source.Value
is null, It'd just pass null to the selector and I'd be up to the developer to determine what to do (I actually do this in the sample application). Likewise, Enumerable's SelectMany would pass null if there's a null item in the source list, which is the equivalent situation. It doesn't really make sense to think of an empty property since, unlike IObservable, you can get its value at any time. I think what you're talking about is a mix of this and the nullable monad. \$\endgroup\$Juan– Juan2016年07月13日 19:41:11 +00:00Commented Jul 13, 2016 at 19:41 -
\$\begingroup\$ BTW, a mix of nullable and property monads could be easily written with an alternative
SelectMany
with this code:source.SelectMany(val => val == null ? null : selector(val))
. But I don't think It'd be a good idea to force this mixed monad, since there's no way to go back to the pure property monad (unlike the other way around). Also, I'd be forcing users to use nullable types only. \$\endgroup\$Juan– Juan2016年07月13日 19:46:53 +00:00Commented Jul 13, 2016 at 19:46 -
\$\begingroup\$ Actually my code was wrong, it should be
source.SelectMany(val => val == null ? PropertySource.Return<T>(null) : selector(val))
\$\endgroup\$Juan– Juan2016年07月13日 20:00:00 +00:00Commented Jul 13, 2016 at 20:00
INotifyPropertyChanged
. \$\endgroup\$