Skip to content

Commit

Permalink
Implement basic cycle detection! (#4)
Browse files Browse the repository at this point in the history
* Add basic cycle detection

uses an ArrayDeque as a stack. currently this is not thread safe and
will need to be modified when a concurrent strategy is implemented

Adds and removes the `System.identityHashCode()` from a stack. when the
hash code is detected in the list of currently processing nodes, a cycle
has been detected. Add the hash code to a list of identifiers (in case
we encounter many cycles in a single node tree, and then immediately
return.

This is faster than object comparison and has relatively low memory
overhead, but is most likely not optimal

* Add cycle detection tests

* Add readme bit for cycle detection

add another test for small objects
  • Loading branch information
snowe2010 committed Feb 14, 2019
1 parent 2b1914d commit c627897
Show file tree
Hide file tree
Showing 5 changed files with 244 additions and 19 deletions.
25 changes: 25 additions & 0 deletions README.md
Expand Up @@ -30,6 +30,31 @@ NestedObjectWithCollection(
)
```

### Cyclic references

```kotlin
data class SmallCyclicalObject1(
val c: SmallCyclicalObject2? = null
)
data class SmallCyclicalObject2(
val c: SmallCyclicalObject1? = null
)
val sco1 = SmallCyclicalObject1()
val sco2 = SmallCyclicalObject2(sco1)
sco1.c = sco2
pp(sco1)
```
prints
```text
ObjectWithMap(
map = {
1 -> ObjectContainingObjectWithMap(
objectWithMap = cyclic reference detected for 775386112
)
}
)[$id=775386112]
```

# ToDo

* implement cycle detection
Expand Down
Expand Up @@ -2,11 +2,15 @@ package com.tylerthrailkill.helpers.prettyprint

import mu.KotlinLogging
import java.io.PrintStream
import java.util.*

private val logger = KotlinLogging.logger {}
private var TAB_SIZE = 2
private var PRINT_STREAM = System.out

typealias CurrentNodeIdentityHashCodes = ArrayDeque<Int>
typealias DetectedCycles = MutableList<Int>

/**
* Pretty print function.
*
Expand All @@ -19,10 +23,12 @@ private var PRINT_STREAM = System.out
fun pp(obj: Any?, tabSize: Int = 2, printStream: PrintStream = System.out) {
TAB_SIZE = tabSize
PRINT_STREAM = printStream
val nodeList: CurrentNodeIdentityHashCodes = ArrayDeque()
val detectedCycles: DetectedCycles = mutableListOf()
when (obj) {
is Iterable<*> -> recurseIterable(obj, "")
is Map<*, *> -> recurseMap(obj, "")
is Any -> recurse(obj)
is Iterable<*> -> recurseIterable(obj, nodeList, detectedCycles, "")
is Map<*, *> -> recurseMap(obj, nodeList, detectedCycles, "")
is Any -> recurse(obj, nodeList, detectedCycles)
else -> writeLine("null")
}
PRINT_STREAM.println()
Expand Down Expand Up @@ -50,7 +56,19 @@ fun <T> T.pp(tabSize: Int = 2, printStream: PrintStream = System.out): T =
* If Map then recurse and deepen the tab size
* else recurse back into this function
*/
private fun recurse(obj: Any, currentDepth: String = "") {
private fun recurse(
obj: Any,
nodeList: CurrentNodeIdentityHashCodes,
detectedCycles: DetectedCycles,
currentDepth: String = ""
) {
val currentObjectIdentity = System.identityHashCode(obj)
if (nodeList.contains(currentObjectIdentity)) {
write("cyclic reference detected for $currentObjectIdentity")
detectedCycles.add(currentObjectIdentity)
return
}
nodeList.push(currentObjectIdentity)
val className = "${obj.javaClass.simpleName}("
write(className)

Expand All @@ -62,22 +80,39 @@ private fun recurse(obj: Any, currentDepth: String = "") {
val fieldValue = it.get(obj)
logger.debug { "field value is ${fieldValue.javaClass}" }
when {
fieldValue is Iterable<*> -> recurseIterable(fieldValue, deepen(pad, it.name.length + 3))
fieldValue is Map<*, *> -> recurseMap(fieldValue, deepen(pad, it.name.length + 3))
fieldValue is Iterable<*> -> recurseIterable(fieldValue, nodeList, detectedCycles, deepen(pad, it.name.length + 3))
fieldValue is Map<*, *> -> recurseMap(fieldValue, nodeList, detectedCycles, deepen(pad, it.name.length + 3))
fieldValue == null -> write("null")
fieldValue.javaClass.name.startsWith("java") -> write(fieldValue.toString())
else -> recurse(fieldValue, deepen(currentDepth))
else -> recurse(fieldValue, nodeList, detectedCycles, deepen(currentDepth))
}
}
PRINT_STREAM.println()
write("$currentDepth)")
if (detectedCycles.contains(currentObjectIdentity)) {
write("[\$id=$currentObjectIdentity]")
detectedCycles.remove(currentObjectIdentity)
}
nodeList.pop()
}

/**
* Same as `recurse`, but meant for iterables. Handles deepening in appropriate areas
* and calling back to `recurse`, `recurseIterable`, or `recurseMap`
*/
private fun recurseIterable(obj: Iterable<*>, currentDepth: String) {
private fun recurseIterable(
obj: Iterable<*>,
nodeList: CurrentNodeIdentityHashCodes,
detectedCycles: DetectedCycles,
currentDepth: String
) {
val currentObjectIdentity = System.identityHashCode(obj)
if (nodeList.contains(currentObjectIdentity)) {
write("cyclic reference detected for $currentObjectIdentity")
detectedCycles.add(currentObjectIdentity)
return
}
nodeList.push(currentObjectIdentity)
var commas = obj.count() // comma counter

// begin writing the iterable
Expand All @@ -86,8 +121,8 @@ private fun recurseIterable(obj: Iterable<*>, currentDepth: String) {
val increasedDepth = currentDepth + " ".repeat(TAB_SIZE)
write(increasedDepth) // write leading spacing
when {
it is Iterable<*> -> recurseIterable(it, increasedDepth)
it is Map<*, *> -> recurseMap(it, increasedDepth)
it is Iterable<*> -> recurseIterable(it, nodeList, detectedCycles, increasedDepth)
it is Map<*, *> -> recurseMap(it, nodeList, detectedCycles, increasedDepth)
it == null -> write("null")
it.javaClass.name.startsWith("java") -> {
if (it is String) {
Expand All @@ -98,7 +133,7 @@ private fun recurseIterable(obj: Iterable<*>, currentDepth: String) {
write('"')
}
}
else -> recurse(it, increasedDepth)
else -> recurse(it, nodeList, detectedCycles, increasedDepth)
}
// add commas if not the last element
if (commas > 1) {
Expand All @@ -108,13 +143,29 @@ private fun recurseIterable(obj: Iterable<*>, currentDepth: String) {
PRINT_STREAM.println()
}
write("$currentDepth]")
if (detectedCycles.contains(currentObjectIdentity)) {
write("[\$id=$currentObjectIdentity]")
detectedCycles.remove(currentObjectIdentity)
}
nodeList.pop()
}

/**
* Same as `recurse`, but meant for maps. Handles deepening in appropriate areas
* and calling back to `recurse`, `recurseIterable`, or `recurseMap`
*/
private fun recurseMap(obj: Map<*, *>, currentDepth: String) {
private fun recurseMap(
obj: Map<*, *>,
nodeList: CurrentNodeIdentityHashCodes,
detectedCycles: DetectedCycles, currentDepth: String
) {
val currentObjectIdentity = System.identityHashCode(obj)
if (nodeList.contains(currentObjectIdentity)) {
write("cyclic reference detected for $currentObjectIdentity")
detectedCycles.add(currentObjectIdentity)
return
}
nodeList.push(currentObjectIdentity)
var commas = obj.count() // comma counter

// begin writing the iterable
Expand All @@ -123,8 +174,8 @@ private fun recurseMap(obj: Map<*, *>, currentDepth: String) {
val increasedDepth = currentDepth + " ".repeat(TAB_SIZE)
write(increasedDepth) // write leading spacing
when {
k is Iterable<*> -> recurseIterable(k, increasedDepth)
k is Map<*, *> -> recurseMap(k, increasedDepth)
k is Iterable<*> -> recurseIterable(k, nodeList, detectedCycles, increasedDepth)
k is Map<*, *> -> recurseMap(k, nodeList, detectedCycles, increasedDepth)
k == null -> write("null")
k.javaClass.name.startsWith("java") -> {
if (k is String) {
Expand All @@ -135,12 +186,12 @@ private fun recurseMap(obj: Map<*, *>, currentDepth: String) {
write('"')
}
}
else -> recurse(k, increasedDepth)
else -> recurse(k, nodeList, detectedCycles, increasedDepth)
}
write(" -> ")
when {
v is Iterable<*> -> recurseIterable(v, increasedDepth)
v is Map<*, *> -> recurseMap(v, increasedDepth)
v is Iterable<*> -> recurseIterable(v, nodeList, detectedCycles, increasedDepth)
v is Map<*, *> -> recurseMap(v, nodeList, detectedCycles, increasedDepth)
v == null -> write("null")
v.javaClass.name.startsWith("java") -> {
if (v is String) {
Expand All @@ -151,7 +202,7 @@ private fun recurseMap(obj: Map<*, *>, currentDepth: String) {
write('"')
}
}
else -> recurse(v, increasedDepth)
else -> recurse(v, nodeList, detectedCycles, increasedDepth)
}
// add commas if not the last element
if (commas > 1) {
Expand All @@ -161,6 +212,11 @@ private fun recurseMap(obj: Map<*, *>, currentDepth: String) {
PRINT_STREAM.println()
}
write("$currentDepth}")
if (detectedCycles.contains(currentObjectIdentity)) {
write("[\$id=$currentObjectIdentity]")
detectedCycles.remove(currentObjectIdentity)
}
nodeList.pop()
}

/**
Expand Down
@@ -0,0 +1,124 @@
package com.tylerthrailkill.helpers.prettyprint

import org.spekframework.spek2.Spek
import org.spekframework.spek2.style.specification.describe

object CycleDetectionTest : Spek({
setup()

describe("pretty printing") {
context("plain objects with cycles") {
it("should detect a cycle with plain Unit") {
val unit = Unit
val identity = System.identityHashCode(unit)
prettyPrint(unit) mapsTo """
Unit(
INSTANCE = cyclic reference detected for $identity
)[${'$'}id=$identity]
"""
}
it("should detect a cycle with two small objects") {
val sco1 = SmallCyclicalObject1()
val sco2 = SmallCyclicalObject2(sco1)
sco1.c = sco2
val identity = System.identityHashCode(sco1)
prettyPrint(sco1) mapsTo """
SmallCyclicalObject1(
c = SmallCyclicalObject2(
c = cyclic reference detected for $identity
)
)[${'$'}id=$identity]
"""
}
it("should detect no cycle when an element is repeated several times in the same objects fields") {
val smallObject = SmallObject("a string in small object", 777)
val nestedLargeObjectNull = NestedLargeObject(
NestedSmallObject(smallObject),
smallObject,
"test string, please don't break",
null
)
prettyPrint(nestedLargeObjectNull) mapsTo """
NestedLargeObject(
nestedSmallObject = NestedSmallObject(
smallObject = SmallObject(
field1 = a string in small object
field2 = 777
)
)
smallObject = SmallObject(
field1 = a string in small object
field2 = 777
)
testString = test string, please don't break
bigObject = null
)
"""
}
}
context("maps with cycles") {
it("should detect a cycle between an object with a map with an object with a cycle") {
val objectWithMap = ObjectWithMap(
mutableMapOf(1 to null)
)
val objectContainingObjectWithMap = ObjectContainingObjectWithMap()
objectContainingObjectWithMap.objectWithMap = objectWithMap
objectWithMap.map[1] = objectContainingObjectWithMap
val identity = System.identityHashCode(objectWithMap)
prettyPrint(objectWithMap) mapsTo """
ObjectWithMap(
map = {
1 -> ObjectContainingObjectWithMap(
objectWithMap = cyclic reference detected for $identity
)
}
)[${'$'}id=$identity]
""".trimIndent()
}
it("should detect a cycle of a map containing itself") {
val outerMap: MutableMap<Int, Any?> = mutableMapOf(1 to null)
val innerMap = mutableMapOf(1 to outerMap)
outerMap[1] = innerMap
val identity = System.identityHashCode(outerMap)
prettyPrint(outerMap) mapsTo """
{
1 -> {
1 -> cyclic reference detected for $identity
}
}[${'$'}id=$identity]
""".trimIndent()
}
}
context("lists with cycles") {
it("should detect a cycle between an object with a list with an object with a cycle") {
val objectWithList = ObjectWithList(mutableListOf())
val objectContainingObjectWithList = ObjectContainingObjectWithList()
objectContainingObjectWithList.objectWithList = objectWithList
objectWithList.list.add(objectContainingObjectWithList)
val identity = System.identityHashCode(objectWithList)
prettyPrint(objectWithList) mapsTo """
ObjectWithList(
list = [
ObjectContainingObjectWithList(
objectWithList = cyclic reference detected for $identity
)
]
)[${'$'}id=$identity]
""".trimIndent()
}
it("should detect a cycle of a list containing itself") {
val outerList: MutableList<Any?> = mutableListOf()
val innerList = mutableListOf(outerList)
outerList.add(innerList)
val identity = System.identityHashCode(outerList)
prettyPrint(outerList) mapsTo """
[
[
cyclic reference detected for $identity
]
][${'$'}id=$identity]
""".trimIndent()
}
}
}
})
20 changes: 20 additions & 0 deletions src/test/kotlin/com/tylerthrailkill/helpers/prettyprint/Objects.kt
Expand Up @@ -50,3 +50,23 @@ data class EmailAddress(
) {
private val serialVersionUUID = 1L
}

// cyclical objects
data class SmallCyclicalObject1(
var c: SmallCyclicalObject2? = null
)
data class SmallCyclicalObject2(
val c: SmallCyclicalObject1? = null
)
data class ObjectContainingObjectWithMap(
var objectWithMap: ObjectWithMap? = null
)
data class ObjectWithMap(
val map: MutableMap<Int, ObjectContainingObjectWithMap?>
)
data class ObjectContainingObjectWithList(
var objectWithList: ObjectWithList? = null
)
data class ObjectWithList(
val list: MutableList<ObjectContainingObjectWithList?>
)
2 changes: 1 addition & 1 deletion src/test/resources/logback-test.xml
Expand Up @@ -10,4 +10,4 @@
<root level="debug">
<appender-ref ref="FILE"/>
</root>
</configuration>
</configuration>

0 comments on commit c627897

Please sign in to comment.