Skip to content

Commit 5eb0583

Browse files
committed
Give types with an implicit numeric conversion Python's number protocol
Python operators never consult C# implicit conversions, so a type convertible to decimal or double still raised TypeError on addition, comparison and float(). Such types now get arithmetic, comparison, __float__ and __int__ methods delegating to the conversion, grafted at class binding the same way EnumOps is for enums. Operators the type declares itself are kept.
1 parent 339272b commit 5eb0583

7 files changed

Lines changed: 276 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ This document follows the conventions laid out in [Keep a CHANGELOG][].
1010
### Added
1111

1212
- Extension methods declared in non-framework assemblies are bound to the type they extend, so they can be called from the instance like in C# (e.g. `order_status.is_open()`). Those marked `[EditorBrowsable(EditorBrowsableState.Never)]`, or declared in a class marked so, keep their static form only
13+
- Types with an implicit conversion to a numeric type support Python arithmetic, comparisons, `float()` and `int()` through that conversion, unless they declare the operator themselves
1314
- Ability to instantiate new .NET arrays using `Array[T](dim1, dim2, ...)` syntax
1415
- Python operator method will call C# operator method for supported binary and unary operators ([#1324][p1324]).
1516
- Add GetPythonThreadID and Interrupt methods in PythonEngine
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
using NUnit.Framework;
2+
using Python.Runtime;
3+
4+
namespace Python.EmbeddingTest
5+
{
6+
public class ConvertibleFee
7+
{
8+
public decimal Amount { get; }
9+
10+
public ConvertibleFee(decimal amount)
11+
{
12+
Amount = amount;
13+
}
14+
15+
public static implicit operator decimal(ConvertibleFee fee) => fee.Amount;
16+
}
17+
18+
public class ConvertibleRatio
19+
{
20+
public double Value { get; }
21+
22+
public ConvertibleRatio(double value)
23+
{
24+
Value = value;
25+
}
26+
27+
public static implicit operator double(ConvertibleRatio ratio) => ratio.Value;
28+
}
29+
30+
public class ConvertibleMoney
31+
{
32+
public decimal Amount { get; }
33+
34+
public ConvertibleMoney(decimal amount)
35+
{
36+
Amount = amount;
37+
}
38+
39+
public static implicit operator decimal(ConvertibleMoney money) => money.Amount;
40+
41+
// the type's own operator must keep winning over the synthesized one
42+
public static ConvertibleMoney operator +(ConvertibleMoney a, ConvertibleMoney b) => new(a.Amount + b.Amount);
43+
}
44+
45+
public class NotConvertible
46+
{
47+
}
48+
49+
public class TestImplicitNumericOperators
50+
{
51+
private static dynamic module;
52+
53+
private const string TestModule = @"
54+
from clr import AddReference
55+
AddReference(""System"")
56+
AddReference(""Python.EmbeddingTest"")
57+
from Python.EmbeddingTest import *
58+
59+
fee = ConvertibleFee(2)
60+
other = ConvertibleFee(3)
61+
62+
def arithmetic():
63+
return (fee + other, fee + 1, 1 + fee, fee - other, fee - 1, 10 - fee,
64+
fee * other, fee * 2, 2 * fee, other / fee, fee / 2, 10 / fee, -fee)
65+
66+
def aggregates():
67+
return sum([fee, other]), max(fee, other) is other, sorted([other, fee])[0] is fee
68+
69+
def comparisons():
70+
return (fee < other, fee > other, fee <= other, fee >= other,
71+
fee > 0, fee < 2, fee <= 2, fee >= 2, 1 < fee, 2 >= fee)
72+
73+
def conversions():
74+
return float(fee), int(ConvertibleRatio(1.75)), float(ConvertibleRatio(1.75))
75+
76+
def own_operator_wins():
77+
total = ConvertibleMoney(2) + ConvertibleMoney(3)
78+
return type(total).__name__, total.Amount, ConvertibleMoney(2) - ConvertibleMoney(1)
79+
80+
def not_convertible():
81+
return hasattr(NotConvertible(), '__add__'), hasattr(NotConvertible(), '__float__')
82+
";
83+
84+
[OneTimeSetUp]
85+
public void OneTimeSetUp()
86+
{
87+
PythonEngine.Initialize();
88+
using var _ = Py.GIL();
89+
module = PyModule.FromString("implicit_numeric_operators", TestModule);
90+
}
91+
92+
[OneTimeTearDown]
93+
public void Dispose()
94+
{
95+
PythonEngine.Shutdown();
96+
}
97+
98+
[Test]
99+
public void ArithmeticDelegatesToTheImplicitConversion()
100+
{
101+
using var _ = Py.GIL();
102+
var result = module.arithmetic();
103+
var expected = new[] { 5.0, 3.0, 3.0, -1.0, 1.0, 8.0, 6.0, 4.0, 4.0, 1.5, 1.0, 5.0, -2.0 };
104+
for (var i = 0; i < expected.Length; i++)
105+
{
106+
Assert.AreEqual(expected[i], (double)result[i], $"index {i}");
107+
}
108+
}
109+
110+
[Test]
111+
public void AggregatesWork()
112+
{
113+
using var _ = Py.GIL();
114+
var result = module.aggregates();
115+
Assert.AreEqual(5.0, (double)result[0]);
116+
Assert.IsTrue((bool)result[1]);
117+
Assert.IsTrue((bool)result[2]);
118+
}
119+
120+
[Test]
121+
public void ComparisonsDelegateToTheImplicitConversion()
122+
{
123+
using var _ = Py.GIL();
124+
var result = module.comparisons();
125+
var expected = new[] { true, false, true, false, true, false, true, true, true, true };
126+
for (var i = 0; i < expected.Length; i++)
127+
{
128+
Assert.AreEqual(expected[i], (bool)result[i], $"index {i}");
129+
}
130+
}
131+
132+
[Test]
133+
public void FloatAndIntConversions()
134+
{
135+
using var _ = Py.GIL();
136+
var result = module.conversions();
137+
Assert.AreEqual(2.0, (double)result[0]);
138+
Assert.AreEqual(1, (int)result[1]);
139+
Assert.AreEqual(1.75, (double)result[2]);
140+
}
141+
142+
[Test]
143+
public void TheTypesOwnOperatorsKeepWinning()
144+
{
145+
using var _ = Py.GIL();
146+
var result = module.own_operator_wins();
147+
Assert.AreEqual("ConvertibleMoney", (string)result[0]);
148+
Assert.AreEqual(5m, (decimal)result[1]);
149+
// subtraction is not declared by the type, so the synthesized one applies
150+
Assert.AreEqual(1.0, (double)result[2]);
151+
}
152+
153+
[Test]
154+
public void TypesWithoutAConversionAreUntouched()
155+
{
156+
using var _ = Py.GIL();
157+
var result = module.not_convertible();
158+
Assert.IsFalse((bool)result[0]);
159+
Assert.IsFalse((bool)result[1]);
160+
}
161+
}
162+
}

‎src/runtime/ClassManager.cs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,16 @@ private static ClassInfo GetClassInfo(Type type, ClassBase impl)
415415
info = info.Concat(opsImpl.GetMethods(OpsHelper.BindingFlags)).ToArray();
416416
}
417417
}
418+
else if (ImplicitNumericOps.IsSupported(type))
419+
{
420+
// Types with an implicit numeric conversion get Python's number protocol, keeping the operators they declare themselves
421+
var ops = ImplicitNumericOps.GetMethods(type).Where(op => !local.Contains(op.Name)).ToArray();
422+
foreach (var op in ops)
423+
{
424+
local.Add(op.Name);
425+
}
426+
info = info.Concat(ops).ToArray();
427+
}
418428

419429
// Extension methods are bound as members so they can be called from the instance. A real member with the same name wins, as in C#
420430
var extensionMethods = ExtensionMethods.GetMethodsToBind(type).Where(em => !originalMemberNames.Contains(em.Name)).ToArray();

‎src/runtime/Native/ITypeOffsets.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ interface ITypeOffsets
2222
int nb_true_divide { get; }
2323
int nb_and { get; }
2424
int nb_int { get; }
25+
int nb_float { get; }
2526
int nb_or { get; }
2627
int nb_xor { get; }
2728
int nb_lshift { get; }

‎src/runtime/Native/TypeOffset.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ static partial class TypeOffset
3131
internal static int nb_or { get; private set; }
3232
internal static int nb_xor { get; private set; }
3333
internal static int nb_int { get; private set; }
34+
internal static int nb_float { get; private set; }
3435
internal static int nb_lshift { get; private set; }
3536
internal static int nb_rshift { get; private set; }
3637
internal static int nb_remainder { get; private set; }

‎src/runtime/Types/OperatorMethod.cs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ static OperatorMethod()
5656
["op_UnaryPlus"] = new SlotDefinition("__pos__", TypeOffset.nb_positive),
5757

5858
["__int__"] = new SlotDefinition("__int__", TypeOffset.nb_int),
59+
["__float__"] = new SlotDefinition("__float__", TypeOffset.nb_float),
5960
};
6061
ComparisonOpMap = new Dictionary<string, string>
6162
{
@@ -177,6 +178,11 @@ static IEnumerable<MethodInfo> GetOperatorCandidates(Type clrType)
177178
.Concat(typeof(FlagEnumOps<>).MakeGenericType(clrType).GetMethods(flags));
178179
}
179180

181+
if (ImplicitNumericOps.IsSupported(clrType))
182+
{
183+
return clrType.GetMethods(flags).Concat(ImplicitNumericOps.GetMethods(clrType));
184+
}
185+
180186
return clrType.GetMethods(flags);
181187
}
182188

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
using System;
2+
using System.Linq;
3+
using System.Linq.Expressions;
4+
using System.Reflection;
5+
6+
namespace Python.Runtime
7+
{
8+
/// <summary>
9+
/// Locates the implicit numeric conversion of a CLR type. Python operators never consult implicit
10+
/// conversions, so types declaring one get the number protocol through <see cref="ImplicitNumericOps{T}"/>.
11+
/// </summary>
12+
internal static class ImplicitNumericOps
13+
{
14+
private static readonly Type[] NumericTypes =
15+
{
16+
typeof(decimal), typeof(double), typeof(float), typeof(long), typeof(int), typeof(short), typeof(sbyte),
17+
typeof(ulong), typeof(uint), typeof(ushort), typeof(byte)
18+
};
19+
20+
internal static MethodInfo? GetConversion(Type type)
21+
{
22+
if (type.IsEnum || type.IsPrimitive || type == typeof(decimal) || type.ContainsGenericParameters)
23+
{
24+
return null;
25+
}
26+
27+
return type.GetMethods(BindingFlags.Public | BindingFlags.Static)
28+
.FirstOrDefault(method => method.Name == "op_Implicit"
29+
&& NumericTypes.Contains(method.ReturnType)
30+
&& method.GetParameters().Length == 1
31+
&& method.GetParameters()[0].ParameterType == type);
32+
}
33+
34+
internal static bool IsSupported(Type type) => GetConversion(type) != null;
35+
36+
internal static MethodInfo[] GetMethods(Type type)
37+
{
38+
return typeof(ImplicitNumericOps<>).MakeGenericType(type).GetMethods(OpsHelper.BindingFlags);
39+
}
40+
}
41+
42+
[Ops]
43+
internal static class ImplicitNumericOps<T>
44+
{
45+
private static readonly Func<T, double> ToDouble = BuildConversion();
46+
47+
#pragma warning disable IDE1006 // Naming Styles - must match Python
48+
[ForbidPythonThreads]
49+
public static double __float__(T value) => ToDouble(value);
50+
51+
[ForbidPythonThreads]
52+
public static long __int__(T value) => (long)Math.Truncate(ToDouble(value));
53+
#pragma warning restore IDE1006
54+
55+
public static double op_UnaryNegation(T value) => -ToDouble(value);
56+
57+
public static double op_Addition(T a, T b) => ToDouble(a) + ToDouble(b);
58+
public static double op_Addition(T a, double b) => ToDouble(a) + b;
59+
public static double op_Addition(double a, T b) => a + ToDouble(b);
60+
61+
public static double op_Subtraction(T a, T b) => ToDouble(a) - ToDouble(b);
62+
public static double op_Subtraction(T a, double b) => ToDouble(a) - b;
63+
public static double op_Subtraction(double a, T b) => a - ToDouble(b);
64+
65+
public static double op_Multiply(T a, T b) => ToDouble(a) * ToDouble(b);
66+
public static double op_Multiply(T a, double b) => ToDouble(a) * b;
67+
public static double op_Multiply(double a, T b) => a * ToDouble(b);
68+
69+
public static double op_Division(T a, T b) => ToDouble(a) / ToDouble(b);
70+
public static double op_Division(T a, double b) => ToDouble(a) / b;
71+
public static double op_Division(double a, T b) => a / ToDouble(b);
72+
73+
// Python reflects a reversed comparison onto the other operand, so only the forward overloads are needed
74+
public static bool op_LessThan(T a, T b) => ToDouble(a) < ToDouble(b);
75+
public static bool op_LessThan(T a, double b) => ToDouble(a) < b;
76+
77+
public static bool op_GreaterThan(T a, T b) => ToDouble(a) > ToDouble(b);
78+
public static bool op_GreaterThan(T a, double b) => ToDouble(a) > b;
79+
80+
public static bool op_LessThanOrEqual(T a, T b) => ToDouble(a) <= ToDouble(b);
81+
public static bool op_LessThanOrEqual(T a, double b) => ToDouble(a) <= b;
82+
83+
public static bool op_GreaterThanOrEqual(T a, T b) => ToDouble(a) >= ToDouble(b);
84+
public static bool op_GreaterThanOrEqual(T a, double b) => ToDouble(a) >= b;
85+
86+
private static Func<T, double> BuildConversion()
87+
{
88+
var conversion = ImplicitNumericOps.GetConversion(typeof(T))
89+
?? throw new InvalidOperationException($"{typeof(T)} has no implicit numeric conversion");
90+
var value = Expression.Parameter(typeof(T), "value");
91+
var body = Expression.Convert(Expression.Call(conversion, value), typeof(double));
92+
return Expression.Lambda<Func<T, double>>(body, value).Compile();
93+
}
94+
}
95+
}

0 commit comments

Comments
 (0)