Skip to content

Instantly share code, notes, and snippets.

@saitonakamura
Last active October 27, 2021 03:29
Show Gist options
  • Save saitonakamura/d51aa672c929e35cc81fa5a0e31f12a9 to your computer and use it in GitHub Desktop.
Save saitonakamura/d51aa672c929e35cc81fa5a0e31f12a9 to your computer and use it in GitHub Desktop.
Function that replace circular js object dependency with "[Circular]" so it can be consumed by JSON.stringify
// DISCLAIMER
// Original function was updated to a faster and es5-supporting version by @Quacky2200
var replaceCircular = function(val, cache) {
cache = cache || new WeakSet();
if (val && typeof(val) == 'object') {
if (cache.has(val)) return '[Circular]';
cache.add(val);
var obj = (Array.isArray(val) ? [] : {});
for(var idx in val) {
obj[idx] = replaceCircular(val[idx], cache);
}
cache.delete(val);
return obj;
}
return val;
};
// var a = { f: "asd", b: undefined }
// var b = { h: 123, a: a }
// a.b = b
// console.log(JSON.stringify(replaceCircular(a)))
// { "f": "asd", "b": { "h": 123, "a": "[Circular]" } }
@logidelic
Copy link

Nifty, but FYI you have a bug: This will destroy arrays.

@saitonakamura
Copy link
Author

Thanks @logidelic, I fixed that!

@logidelic
Copy link

@saitonakamura : Good stuff, thanks for this. However, don't you also want to do an already.delete(obj) before returning, after the array condition?

@Quacky2200
Copy link

Quacky2200 commented Mar 5, 2020

I decided to make this a little bit simpler since in an old NodeJS version, lambdas aren't supported, and in general, a small switch statement and map/forEach can be considered slow (more function invocations). I removed the level variable since there was no implementation of it other than passing it on the recurring loop and simplified the if statements. For example, your use of (!val) return (which is null if it's an object) can be simplified to if (val && typeof(val) === 'object') { /* do something*/ }. You also had some duplicated code with the forEach which I removed.

var stringify = function(obj) {

	var replaceCircular = function(val, cache) {

		cache = cache || new WeakSet();

		if (val && typeof(val) === 'object') {
			if (cache.has(val)) return '[Circular]';

			cache.add(val);

			var obj = (Array.isArray(val) ? [] : {});
			for(var idx in val) {
				obj[idx] = replaceCircular(val[idx], cache);
			}

			cache.delete(val);
			return obj;
		}

		return val;
	};

	return JSON.stringify(replaceCircular(obj));
};

Feel free to replace tabs to spaces, var to const, ofc.

@jt0dd
Copy link

jt0dd commented Aug 31, 2020

I just used console.time to benchmark running your version @Quacky2200 as replaceCIrcularFast and his as replaceCircular 100k times each, and yours runs approximately twice as slow.

decyled
{
    abc:"Hello",
    circular:"CIRCULAR"
}
replaceCircular: 33ms
decyled fast
{
    abc:"Hello",
    circular: {
        abc:"Hello",
        circular:"CIRCULAR"
    }
}
replaceCircularFast: 64ms

my test code:

const replaceCircular = (obj, level = 0, already = new WeakSet()) => {
  switch (typeof obj) {
    case 'object':
      if (!obj)
        return obj
      if (already.has(obj)) {
        return "CIRCULAR"
      }
      already.add(obj)
      if (Array.isArray(obj)) {
        return obj.map(item => replaceCircular(item, level + 1, already))
      }
      const newObj = {}
      Object.keys(obj).forEach(key => {
        const val = replaceCircular(obj[key], level + 1, already)
        newObj[key] = val
      })
      already.delete(obj)
      return newObj
    default:
      return obj;
  }
}

var replaceCircularFast = function(val, cache) {

		cache = cache || new WeakSet();

		if (val && typeof(val) === 'object') {
			if (cache.has(val)) return '[Circular]';

			cache.add(val);

			var obj = (Array.isArray(val) ? [] : {});
			for(var idx in val) {
				obj[idx] = replaceCircular(val[idx], cache);
			}

			cache.delete(val);
			return obj;
		}

		return val;
	};

function Foo() {
  this.abc = "Hello";
  this.circular = this;
}

var foo = new Foo();

console.log("decyled", replaceCircular(foo))
console.time("replaceCircular")
for (let i = 0; i < 100000; i++) {
  replaceCircular(foo)
}
console.timeEnd("replaceCircular")

console.log("decyled fast", replaceCircularFast(foo))
console.time("replaceCircular")
console.time("replaceCircularFast")
for (let i = 0; i < 100000; i++) {
  replaceCircularFast(foo)
}
console.timeEnd("replaceCircularFast")

@Quacky2200
Copy link

Quacky2200 commented Aug 31, 2020

My original stringify method is very slow with a JSON.stringify. I chose to go from a recursive to a iterative stack method which trades function overhead for storage to gain slight performance improvements. The new function doesn't bother creating any new objects, stack holds the parent value and child key to modify itself. As you can see in the replaceCircularFaster function I added (albeit the JSON.stringify).

var stringify = function(obj) {

    var cache = new WeakSet();
    var stack = [{obj: arguments, key: 0}];

    while (stack.length > 0) {
        var item = stack.pop();
        var item_val = item.obj[item.key];
        
        if (typeof(item_val) == 'object') {
            if (cache.has(item_val)) {
                item.obj[item.key] = '[Circular]';
                continue;
            }
            cache.add(item_val);
            for(var idx in item_val) {
                stack.push({obj: item_val, key: idx});
            }
        }
    }
    return JSON.stringify(obj);
};

Edit: See below comment

@Quacky2200
Copy link

Quacky2200 commented Aug 31, 2020

Sorry to spam the comment section...

@jt0dd I noticed you're calling the original function replaceCircular in the replaceCircularFast function. When I change this to replaceCircularFast, this changes the result to ~50ms.

I also decided to run more tests with slight differences before actually realising the mistake above... I thought it was quite interesting. The array lamdba functions used in the original can still take over 100ms worst case. The iteration function from my last comment is still somewhat quite low in my results, yet it's actually higher than my original recursive refactor comment!

The iteration function forgets to create a new instance which causes the original argument object to change (whoops, my bad 😞 ). My original comment seems to give the lowest result, type equality (===) differences give a teeny-tiny neglible speed boost.

Test Code

// Original post
// Can be harder to read, relies on Array function with lambdas, switch case
var replaceCircular1 = function(obj, level = 0, already = new WeakSet()) {
  switch (typeof obj) {
    case 'object':
      if (!obj)
        return obj
      if (already.has(obj)) {
        return "CIRCULAR"
      }
      already.add(obj)
      if (Array.isArray(obj)) {
        return obj.map(item => replaceCircular1(item, level + 1, already))
      }
      const newObj = {}
      Object.keys(obj).forEach(key => {
        const val = replaceCircular1(obj[key], level + 1, already)
        newObj[key] = val
      })
      already.delete(obj)
      return newObj
    default:
      return obj;
  }
}

// Simplified, return early, avoids array obj functions, static cache object
var replaceCircular2 = function(obj) {

    if (!(obj && typeof(obj) == 'object')) return obj;

    var self = replaceCircular2;
    self.cache = self.cache || new WeakSet();
    if (self.cache.has(obj)) return '[Circular]';

    self.cache.add(obj);

    var _new = Array.isArray(obj) ? [] : {};

    for (var idx in obj) {
        _new[idx] = replaceCircular2(obj[idx]);
    }

    self.cache.delete(obj);

    return _new;
};

// Same as before, but using 'self' variable w/ arguments.callee
// retrieving and calling arguments.callee is slow!
var replaceCircular3 = function(obj) {

    if (!(obj && typeof(obj) == 'object')) return obj;

    var self = arguments.callee;
    self.cache = self.cache || new WeakSet();
    if (self.cache.has(obj)) return '[Circular]';

    self.cache.add(obj);

    var _new = Array.isArray(obj) ? [] : {};

    for (var idx in obj) {
        _new[idx] = self(obj[idx]);
    }

    self.cache.delete(obj);

    return _new;
};

// Simplified, Iteration w/ stack (Quacky2200 response 2)
// Alright min/med/max stats but changes the original object, stack is a bit rediculous
var replaceCircular4 = function(obj) {

    var cache = new WeakSet();
    var stack = [{obj: arguments, key: 0}];

    while (stack.length > 0) {
        var item = stack.pop();
        var item_val = item.obj[item.key];
        
        if (typeof(item_val) == 'object') {
            if (cache.has(item_val)) {
                item.obj[item.key] = '[Circular]';
                continue;
            }
            cache.add(item_val);
            for(var idx in item_val) {
                stack.push({obj: item_val, key: idx});
            }
        }
    }
    return obj;
};

// Simplified, Recursive, Cache Argument (Quacky2200 original response)
// Easiest to understand, fastest to run
var replaceCircular5 = function(val, cache) {

    cache = cache || new WeakSet();

    if (val && typeof(val) === 'object') {
        if (cache.has(val)) return '[Circular]';

        cache.add(val);

        var obj = (Array.isArray(val) ? [] : {});
        for(var idx in val) {
            obj[idx] = replaceCircular5(val[idx], cache);
        }

        cache.delete(val);
        return obj;
    }

    return val;
};

// Similar to replaceCircular5, but removes type equality operation (===)
var replaceCircular6 = function(val, cache) {

    cache = cache || new WeakSet();

    if (val && typeof(val) == 'object') {
        if (cache.has(val)) return '[Circular]';

        cache.add(val);

        var obj = (Array.isArray(val) ? [] : {});
        for(var idx in val) {
            obj[idx] = replaceCircular6(val[idx], cache);
        }

        cache.delete(val);
        return obj;
    }

    return val;
};

function Foo() {
  this.abc = "Hello";
  this.circular = this;
}

var foo;

var stats = {
    replaceCircular1: [],
    replaceCircular2: [],
    replaceCircular3: [],
    replaceCircular4: [],
    replaceCircular5: [],
    replaceCircular6: []
};

for (var test = 0; test < 50; test++) {
    //console.log(`Test ${test}/50`);
    var now = Date.now();
    for (let i = 0; i < 100000; i++) {
      foo = new Foo();
      replaceCircular1(foo)
    }
    stats.replaceCircular1.push(Date.now() - now)
    
    now = Date.now()
    for (let i = 0; i < 100000; i++) {
      foo = new Foo();
      replaceCircular2(foo)
    }
    stats.replaceCircular2.push(Date.now() - now)

    now = Date.now()
    for (let i = 0; i < 100000; i++) {
      foo = new Foo();
      replaceCircular3(foo)
    }
    stats.replaceCircular3.push(Date.now() - now)

    now = Date.now()
    for (let i = 0; i < 100000; i++) {
      foo = new Foo();
      replaceCircular4(foo)
    }
    stats.replaceCircular4.push(Date.now() - now)

    now = Date.now()
    for (let i = 0; i < 100000; i++) {
      foo = new Foo();
      replaceCircular5(foo)
    }
    stats.replaceCircular5.push(Date.now() - now)

    now = Date.now()
    for (let i = 0; i < 100000; i++) {
      foo = new Foo();
      replaceCircular6(foo)
    }
    stats.replaceCircular6.push(Date.now() - now)
}

for (var stat in stats) {
    console.log(
        stat + ": ", 
        "min: " + stats[stat].sort(function(a,b) {return a-b})[0],
        "med: " + stats[stat].reduce(function(a,b) {return a + b}) / stats[stat].length,
        "max: " + stats[stat].sort(function(a,b) {return b-a})[0]
    );
}

Browser:

replaceCircular1:  min: 47 med: 67.22 max: 292
replaceCircular2:  min: 43 med: 51.82 max: 141
replaceCircular3:  min: 48 med: 54.26 max: 123
replaceCircular4:  min: 46 med: 52.28 max: 103
replaceCircular5:  min: 31 med: 36.44 max: 160
replaceCircular6:  min: 33 med: 37.36 max: 85

replaceCircular1:  min: 51 med: 70.82 max: 187
replaceCircular2:  min: 43 med: 49.22 max: 103
replaceCircular3:  min: 49 med: 54.58 max: 104
replaceCircular4:  min: 45 med: 53.02 max: 150
replaceCircular5:  min: 31 med: 38.38 max: 104
replaceCircular6:  min: 30 med: 36.6 max: 91

Node:

$ node test.js
replaceCircular1:  min: 42 med: 49.04 max: 115
replaceCircular2:  min: 37 med: 40.88 max: 76
replaceCircular3:  min: 45 med: 49.42 max: 71
replaceCircular4:  min: 43 med: 48.16 max: 88
replaceCircular5:  min: 30 med: 32.76 max: 50
replaceCircular6:  min: 28 med: 32.08 max: 73

$ node test.js # intensive pc background tasks :(
replaceCircular1:  min: 48 med: 65.08 max: 441
replaceCircular2:  min: 38 med: 42.24 max: 111
replaceCircular3:  min: 47 med: 52.44 max: 111
replaceCircular4:  min: 43 med: 52.1 max: 201
replaceCircular5:  min: 31 med: 35.7 max: 65
replaceCircular6:  min: 29 med: 33.36 max: 58

$ node test.js
replaceCircular1:  min: 43 med: 48.88 max: 117
replaceCircular2:  min: 37 med: 39.36 max: 61
replaceCircular3:  min: 45 med: 48.84 max: 67
replaceCircular4:  min: 43 med: 46.48 max: 78
replaceCircular5:  min: 30 med: 32.7 max: 48
replaceCircular6:  min: 29 med: 31.16 max: 55

@saitonakamura
Copy link
Author

Wow, you people are great! @Quacky2200 I'll update the original to replaceCircular6 and mention you if you don't mind

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment