Hi,
Jurassic is a really great library, and in our product at work where we use it to allow the user to program the application with JavaScripts/TypeScripts (using the Monaco Editor), gradually more advanced use cases appear while building the interface between JavaScript and .NET.
The wiki page Exposing a .NET class to JavaScript shows an example how to create a constructor and instance for static and instance functions.
However as I understand it, when the Random
function is called as a constructor using new Random(...)
, its JSConstructorFunction
always creates and returns a new RandomInstance
object. This means the class cannot be subclassed in JavaScript because in that case the constructor function would need to be run with an existing ObjectInstance
that has a prototype of the subclass.
For example, consider the following TypeScript that tries to subclass the existing Random
class that we exposed from .NET, using a new EnhancedRandom
class:
// Declare the existing class
declare class Random {
constructor(seed: number);
nextDouble(): number;
}
class EnhancedRandom extends Random {
constructor(seed: number) {
super(seed);
}
nextInt(maxValue: number) {
return Math.floor(this.nextDouble() * maxValue);
}
}
let myRandom = new Random(1000);
let enhanced = new EnhancedRandom(1000);
console.log(myRandom.nextDouble());
console.log(myRandom.nextDouble());
console.log(enhanced.nextDouble());
console.log(enhanced.nextInt(5000));
When compiling down to ECMAScript 5, the following JS file is produced by TS:
var __extends = (this && this.__extends) || function (d, b) {
for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p];
function __() { this.constructor = d; }
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
};
var EnhancedRandom = (function (_super) {
__extends(EnhancedRandom, _super);
function EnhancedRandom(seed) {
_super.call(this, seed);
}
EnhancedRandom.prototype.nextInt = function (maxValue) {
return Math.floor(this.nextDouble() * maxValue);
};
return EnhancedRandom;
}(Random));
var myRandom = new Random(1000);
var enhanced = new EnhancedRandom(1000);
console.log(myRandom.nextDouble());
console.log(myRandom.nextDouble());
console.log(enhanced.nextDouble());
console.log(enhanced.nextInt(5000));
When subclassing the Random
class, a new function EnhancedRandom
is created and its prototype
property is set to a new object that has a prototype of Random.prototype
. When the script calls new EnhancedRandom(1000)
, a new object is created with a prototype of EnhancedRandom
, and the EnhancedRandom
function is called with the 'this' object being the just created new object.
Now, when running the JS code, the call new EnhancedRandom(1000)
produces an InvalidCastException:
System.InvalidCastException: Unable to cast object of type 'SubclassExample.RandomConstructor' to type '<>c'.
at binder_for_Jurassic.Library.ClrFunction+<>c.<.ctor>b__3_0(ScriptEngine , Object , Object[] )
at Jurassic.Compiler.Binder.Call(ScriptEngine engine, Object thisObject, Object[] arguments) in C:\Users\Name\Desktop\JurassicTest\jurassic\Jurassic\Compiler\Binders\Binder.cs:line 72
at Jurassic.Library.ClrFunction.CallLateBound(Object thisObject, Object[] arguments) in C:\Users\Name\Desktop\JurassicTest\jurassic\Jurassic\Library\Function\ClrFunction.cs:line 178
(...)
(Maybe an JavaScriptException
should be thrown in this case so the JS code has a change to catch the exception.)
Now, in order to make Random
subclassable, AFAIK what needs to be changed is:
- Change the
RandomConstructor
to not create and return a new object, but instead work with the supplied, existing ObjectInstance
.
- Due to the above change, the
RandomInstance
class cannot be used as instance class anymore (containing the private Random random;
field as shown in the Wiki page), because we may have to work with an existing ObjectInstance
.
To fix 2), one can use a ConditionalWeakTable
to attach an extension object to existing ObjectInstance
s where the private data can be stored.
To fix 1), I think one cannot use the ClrFunction
class because I did not found a way to have a declared .NET function be called with the correct 'this' object (it seems that the 'this' object is always the FunctionInstance
, not the new created object), and it overwrites FunctionInstance.ConstructLateBound()
to do its own logic, whereas I would need the logic of the FunctionInstance
which is the JavaScript logic:
- Create new object with a prototype of
FunctionInstance.InstancePrototype
- Call the function, using the newly created object as 'this' value
- If the function returned an
ObjectInstance
, the value of the new ...
expression is the returned object, otherwise it is the previously created object.
The only way I found to do this is to directly extend the FunctionInstance
class in the following way:
using System;
using System.Runtime.CompilerServices;
using Jurassic;
using Jurassic.Library;
namespace SubclassExample
{
public class RandomConstructor : FunctionInstance
{
private const int typicalArgumentsLength = 1;
private ScriptEngine engine;
private RandomInstance instancePrototype;
public RandomConstructor(ScriptEngine engine, string name)
: base(engine.Function.InstancePrototype)
{
this.engine = engine;
this.instancePrototype = new RandomInstance(engine.Object.InstancePrototype);
// Set function properties.
this.DefineProperty("name", new PropertyDescriptor(
name, PropertyAttributes.Configurable), true);
this.DefineProperty("length", new PropertyDescriptor(
typicalArgumentsLength, PropertyAttributes.Configurable), true);
this.DefineProperty("prototype", new PropertyDescriptor(
this.instancePrototype, PropertyAttributes.Writable), true);
this.instancePrototype.DefineProperty("constructor", new PropertyDescriptor(
this, PropertyAttributes.Configurable | PropertyAttributes.Writable), true);
PopulateFunctions();
}
private void CheckPrototype(object obj)
{
// Check if the object's prototype is correct.
ObjectInstance prototype = obj as ObjectInstance;
while (true)
{
prototype = prototype?.Prototype;
if (prototype == null)
throw new JavaScriptException(this.Engine, ErrorType.TypeError,
"Cannot call a class as a function");
if (prototype == this.instancePrototype)
break;
}
}
public override object CallLateBound(
object thisObject, params object[] argumentValues)
{
CheckPrototype(thisObject);
int? seed = null;
if (argumentValues.Length > 0)
{
var seedArg = argumentValues[0];
if (seedArg is int)
seed = (int)seedArg;
else if (seedArg is double)
seed = (int)(double)seedArg;
}
if (!seed.HasValue)
throw new JavaScriptException(this.Engine, ErrorType.TypeError,
"Invalid argument");
ConstructCore(thisObject as ObjectInstance, seed.Value);
// Don't return a value. When the function is called with the "new" keyword, the
// value of the "new ...()" expression will be the previously created object.
return Undefined.Value;
}
/// <summary>
/// Constructs a new <see cref="RandomInstance"/> from .NET code.
/// </summary>
/// <param name="seed"></param>
public ObjectInstance ConstructNew(int seed)
{
// Create a new object with our prototype.
ObjectInstance newObject = ObjectConstructor.Create(
this.engine, this.instancePrototype);
ConstructCore(newObject, seed);
return newObject;
}
/// <summary>
/// Runs the constructor on an existing object.
/// </summary>
/// <param name="thisObject"></param>
/// <param name="seed"></param>
protected virtual void ConstructCore(ObjectInstance thisObject, int seed)
{
// Construct the instance.
RandomInstance.ConstructInstance(thisObject, seed);
}
}
public class RandomInstance : ObjectInstance
{
private static ConditionalWeakTable<ObjectInstance, RandomInstanceExtension>
instanceTable =
new ConditionalWeakTable<ObjectInstance, RandomInstanceExtension>();
/// <summary>
/// Creates the instance prototype for the constructor function.
/// </summary>
/// <param name="prototype"></param>
public RandomInstance(ObjectInstance prototype)
: base(prototype)
{
// Construct the Prototype.
this.PopulateFunctions();
}
public static void ConstructInstance(ObjectInstance obj, int seed)
{
// Run the instance constructor code.
RandomInstanceExtension instance;
if (instanceTable.TryGetValue(obj, out instance))
throw new JavaScriptException(obj.Engine, ErrorType.Error,
"Constructor has already been called");
instance = new RandomInstanceExtension()
{
Random = new Random(seed)
};
instanceTable.Add(obj, instance);
}
private static RandomInstanceExtension GetInstanceExtension(
ObjectInstance thisObj)
{
RandomInstanceExtension instance;
if (!instanceTable.TryGetValue(thisObj, out instance))
throw new JavaScriptException(thisObj.Engine, ErrorType.TypeError,
"Method is not generic");
return instance;
}
[JSFunction(Name = "nextDouble", Flags = JSFunctionFlags.HasThisObject)]
public static double NextDouble(ObjectInstance thisObject)
{
RandomInstanceExtension instance = GetInstanceExtension(thisObject);
return instance.Random.NextDouble();
}
private class RandomInstanceExtension
{
public Random Random { get; set; }
}
}
}
In the above code, .NET code can still construct a new RandomInstance with RandomConstructor.ConstructNew()
.
Running the script then produces the result:
0.15155745910087481
0.2359429496507826
0.15155745910087481
1179
Is this the correct way to expose build native classes that are subclassable?
If yes, I think this could be added to the Wiki page.
Thanks!