Joppy
Joppy

Reputation: 403

Making a true copy of a function in Javascript

I would like to define two functions (or classes) in Javascript with the exact same function body, but have them be completely different objects. The use-case for this is that I have some common logic in the body which is polymorphic (the function can accept multiple types), but by only calling the function with a single type the function ends up faster, I assume since the JIT can take a happier fast path in each case.

One way of doing this is simply to repeat the function body entirely:

function func1(x) { /* some body */ }
function func2(x) { /* some body */ }

Another way of accomplishing the same thing with less repetition is eval():

function func(x) { /* some body */ }
function factory() { return eval("(" + func.toString() + ")") }
let func1 = factory(), func2 = factory()

The downside of eval() of course being that any other tools (minifiers, optimisers, etc) are completely taken by surprise and have the potential to mangle my code so this doesn't work.

Are there any sensible ways of doing this within the bounds of a standard toolchain (I use Typescript, esbuild, and Vite), without using eval() trickery or just copy-pasting the code? I also have the analagous question about class definitions.


Edit: to summarise what's been going on in the comments:

  1. Yes, the performance difference is real and measurable (especially on Chrome, less pronounced on Firefox and Safari), as demonstrated by this microbenchmark. The real program motivating this question is much larger and the performance differences are much more pronounced, I suspect because the JIT can do more inlining for monomorphic functions, which has many knock-on effects.
  2. The obvious solution of returning a closure does not work, i.e.
    function factory() { function func() { /* some body */ } return func }
    let func1 = factory(), func2 = factory()
    
    as demonstrated by this second microbenchmark. This is because a JIT will only compile a function body once, even if it is a closure.
  3. It may be the case that this is already the best solution, at least when working within a standard JS/Typescript toolchain (which does not include code-generation or macro facilities).

Upvotes: 20

Views: 1925

Answers (5)

Ed_Johnsen
Ed_Johnsen

Reputation: 156

Here's a vanilla, ES5 solution: (it must be declared globally... and only works on functions that reference globally-reachable content)

function dirtyClone(class_or_function){
    
  if(typeof class_or_function !== "function"){

    console.log("wrong input type");

    return false;
  }


  let stringVersion = class_or_function.toString();

  let newFunction = 'dirtyClone.arr.push(' + stringVersion + ')';


  let funScript = document.createElement("SCRIPT");

  funScript.text = newFunction;

  document.body.append(funScript);

  funScript.remove();


  let last = dirtyClone.arr.length-1;

  dirtyClone.arr[last].prototype = class_or_function.prototype;

  return dirtyClone.arr[last];
}
dirtyClone.arr = [];



// TESTS
class Animal {
  constructor(name) {
    this.name = name;
  }

  speak() {
    console.log(`${this.name} makes a noise.`);
  }
}

class Dog extends Animal {
  constructor(name) {
    super(name); // call the super class constructor and pass in the name parameter
  }

  speak() {
    console.log(`${this.name} barks.`);
  }
}

function aFunc(x){console.log(x);}

let newFunc = dirtyClone(aFunc);
newFunc("y");

let newAni = dirtyClone(Animal);
let nA = new newAni("person");
nA.speak();

let newDog = dirtyClone(Dog);
let nD = new newDog("mutt");
nD.speak();

console.log({newFunc});
console.log({newAni});
console.log({newDog});

Also, just in case your original function has deep properties (no need for global declaration... but it still only works on functions that reference content that is reachable from the global scope).

let dirtyDeepClone = (function(){
    // Create a non-colliding variable name  
    // for an array that will hold functions.
    let alfUUID = "alf_" + makeUUID();
    
    // Create a new script element.
    let scriptEl = document.createElement('SCRIPT');
    
    // Add a non-colliding, object declaration 
    // to that new script element's text.
    scriptEl.text = alfUUID + " = [];";
    
    // Append the new script element to the document's body
    document.body.append(scriptEl);
                

    // The function that does the magic
    function dirtyDeepClone(class_or_function){
      
        if(typeof class_or_function !== "function"){

            console.log("wrong input type");

            return false;
        }

        
        let stringVersion = class_or_function.toString();
        
        let newFunction = alfUUID + '.push(' + stringVersion + ')';
    
        
        let funScript = document.createElement("SCRIPT");

        funScript.text = newFunction;
        
        document.body.append(funScript);
    
        funScript.remove();
        
        
        let last = window[alfUUID].length-1;
        
        window[alfUUID][last] = extras(true, class_or_function, window[alfUUID][last]);
      
        window[alfUUID][last].prototype = class_or_function.prototype;
        
        return window[alfUUID][last];
    }



    ////////////////////////////////////////////////
    // SUPPORT FUNCTIONS FOR dirtyDeepClone FUNCTION
    function makeUUID(){
        
        // uuid adapted from: https://stackoverflow.com/a/21963136
        var lut = []; 
        
        for (var i=0; i<256; i++)
            lut[i] = (i<16?'0':'')+(i).toString(16);
        
        
        var d0 = Math.random()*0xffffffff|0;
        var d1 = Math.random()*0xffffffff|0;
        var d2 = Math.random()*0xffffffff|0;
        var d3 = Math.random()*0xffffffff|0;
        
        
        var UUID = lut[d0&0xff]+lut[d0>>8&0xff]+lut[d0>>16&0xff]+lut[d0>>24&0xff]+'_'+
        lut[d1&0xff]+lut[d1>>8&0xff]+'_'+lut[d1>>16&0x0f|0x40]+lut[d1>>24&0xff]+'_'+
        lut[d2&0x3f|0x80]+lut[d2>>8&0xff]+'_'+lut[d2>>16&0xff]+lut[d2>>24&0xff]+
        lut[d3&0xff]+lut[d3>>8&0xff]+lut[d3>>16&0xff]+lut[d3>>24&0xff];
        
        return UUID;
    }
  
  
  // Support variables for extras function
    var errorConstructor = {
        "Error":true,
        "EvalError":true,
        "RangeError":true,
        "ReferenceError":true,
        "SyntaxError":true,
        "TypeError":true,
        "URIError":true
    };
    var filledConstructor = {
        "Boolean":true,
        "Date":true,
        "String":true,
        "Number":true,
        "RegExp":true
    };
    var arrayConstructorsES5 = {
        "Array":true,
        "BigInt64Array":true,
        "BigUint64Array":true,
        "Float32Array":true,
        "Float64Array":true,
        "Int8Array":true,
        "Int16Array":true,
        "Int32Array":true,
        "Uint8Array":true,
        "Uint8ClampedArray":true,
        "Uint16Array":true,
        "Uint32Array":true,
    };
    var filledConstructorES6 = {
        "BigInt":true,
        "Symbol":true
    };


    function extras(top, from, to){
        
        // determine if obj is truthy 
        // and if obj is an object.
        if(from !== null && (typeof from === "object" || top) && !from.isActiveClone){
            
            // stifle further functions from entering this conditional
            // (initially, top === true because we are expecting that to is a function)
            top = false; 
            
            // if object was constructed
            // handle inheritance,
            // or utilize built-in constructors
            if(from.constructor && !to){

                let oType = from.constructor.name;


                if(filledConstructor[oType])
                    to = new from.constructor(from);

                else if(filledConstructorES6[oType])
                    to = from.constructor(from);

                else if(from.cloneNode)
                    to = from.cloneNode(true);

                else if(arrayConstructorsES5[oType])
                    to = new from.constructor(from.length);

                else if ( errorConstructor[oType] ){

                    if(from.stack){

                        to = new from.constructor(from.message);

                        to.stack = from.stack;
                    }

                    else
                        to = new Error(from.message + " INACCURATE OR MISSING STACK-TRACE");
                    
                }

                else // troublesome if constructor is poorly formed
                    to = new from.constructor(); 
                
            }
            
            else // loses cross-frame magic
                to = Object.create(null); 

            
            
            
            let props = Object.getOwnPropertyNames(from);

            let descriptor;


            for(let i in props){

                descriptor = Object.getOwnPropertyDescriptor( from, props[i] );
                prop = props[i];

                // recurse into descriptor, if necessary
                // and assign prop to from
                if(descriptor.value){

                    if(
                      descriptor.value !== null && 
                      typeof descriptor.value === "object" &&
                      typeof descriptor.value.constructor !== "function"
                    ){
                          from.isActiveClone = true;
                          to[prop] = extras(false, from[prop]);
                          delete from.isActiveClone;
                        
                    }
                  else
                        to[prop] = from[prop];
                }
                else
                    Object.defineProperty( to, prop, descriptor );
            }
        }
      
        else if(typeof from === "function")
            return dirtyDeepClone(from);
        
        return from;
    }
    
    return dirtyDeepClone;
})();



// TESTS
class Animal {
  constructor(name) {
    this.name = name;
  }

  speak() {
    console.log(`${this.name} makes a noise.`);
  }
}

class Dog extends Animal {
  constructor(name) {
    super(name); // call the super class constructor and pass in the name parameter
  }

  speak() {
    console.log(`${this.name} barks.`);
  }
}

function aFunc(x){console.log(x);}
aFunc.g = "h";
aFunc.Fun = function(){this.a = "b";}

let newFunc = dirtyDeepClone(aFunc);
newFunc("y");
let deepNewFunc = new newFunc.Fun();
console.log(deepNewFunc);

let newAni = dirtyDeepClone(Animal);
let nA = new newAni("person");
nA.speak();

let newDog = dirtyDeepClone(Dog);
let nD = new newDog("mutt");
nD.speak();

console.log({newFunc});
console.log({newAni});
console.log({newDog});

Upvotes: 1

Hunq Vux
Hunq Vux

Reputation: 37

Playing around with the Function constructor function, I guess that this would do the job

function func(x) { /* some body */ }
function factory() {
    return (
        new Function('return ' + func.toString())
    )();
}

let func1 = factory(), func2 = factory()

Upvotes: 2

Leftium
Leftium

Reputation: 17903

  1. Use the file system to make a single ESM module file appear as multiple different files.
  2. Then import your function multiple times.

The only toolchain requirement is esbuild, but other bundlers like rollup will also work:

Output:

// From command: esbuild  main.js --bundle
(() => {
  // .func1.js
  function func(x2) {
    return x2 + 1;
  }

  // .func2.js
  function func2(x2) {
    return x2 + 1;
  }

  // main.js
  func(x) + func2(x);
})();



// From command: rollup main.js
function func$1(x) { return x+1 }

function func(x) { return x+1 }

func$1(x) + func(x);

With these files as input:

// func.js
export function func(x) { return x+1 }
// main.js
import {func as func1} from './.func1';
import {func as func2} from './.func2';

func1(x) + func2(x)

The imported files are actually hard links to the same file generated by this script:

#!/bin/sh
# generate-func.sh

ln func.js .func1.js
ln func.js .func2.js

To prevent the hard links from messing up your repository, tell git to ignore the generated hard links. Otherwise, the hard links may diverge as separate files if they are checked in and checked out again:

# .gitignore
.func*

Notes

  • I put everything in the same folder for simplicity, but you can generate the hard links in their own folder for organization.
  • Rollup will "see through" this trick if you use symlinks to the same JS file. However symlinks to directories work fine.
  • Tested on git-bash for Windows; YMMV on other platforms.

Upvotes: 5

Bekim Bacaj
Bekim Bacaj

Reputation: 5955

Your idea of having a "factory" or a master function that would produce independent, physically separate, functions instead of referencing to the same one is a very good start...

In times before CSS animations, we had to use JavaScript for creating timed effects and so on. The idea of hovering over elements that would light them up, but as the mouse leaves that element hovering over the other, you'd want them to slowly fade out, ( in sort of leaving a smooth trail of light kind of fashion ), not abruptly over hundreds of elements, it would be impossible to do with a single function, whereas rewriting the same function body with slightly different names hundreds of times would be nonsensical, let alone assigning each function to the target element individually.

We faced the same problem...

To cut the story short, we had to go with a 'sensible solution' as you say, or drop the idea completely..., I didn't!

Here is a logic behind the (Factory) solution and a console log content to prove that these identical twin functions are physically separate (as required) not references to the same.

function Factory( x ){ return function( ){ console.log( x ) } };

func1 = new Factory("I'm the first born!");
func2 = new Factory("I'm the second born!");

func1(); func2();

Hope you find this solution sensible enough.

p.s.: You can add as many arguments you need to the Factory and provide their specific values during the creation of the functions which will be available throughout the session at all times, just as the console.log string we see in the demo is.

Regards.

Upvotes: 0

ahmedazhar05
ahmedazhar05

Reputation: 618

you can try this way:

function func1(x) { /* some body */ }

var func2 = new Function("x", func1.toString().match(/{.+/g)[0].slice(1,-1));

I are defining new function func2(x) using the function constructor where the first n-1 arguments are parameters and the last parameter is the function body

for function body I used regex to extract all the lines in the scope of func1 i.e. between the function braces { and }

you can read more about the Function constructor here

Upvotes: -1

Related Questions