Kotlin 84 - A Class? An Interface? Both?
I want to try another approach to the product-type lookup. I have to try it to see if I like it.
When I made my TYPE an enum, I thought it would help out by allowing me to enforce a rule about all the values in the enum needing to appear in when clauses, but it didn’t break that way. I have another idea:
- Make a class whose instances map the string name of the item to a TYPE.
- Use that to find matches.
- Extend to allow a pattern match rather than equality match.
Giving elements of my p-baked idea those numbers makes it sound more solid than it is. I plan to try it. I would like to do it in such a way that I don’t break the program for more than a moment or two at any point in time. I convert to this:
class ProdToType(val prodName: String, val type: TYPE) {
}
val PRODUCT = listOf<ProdToType>(ProdToType("Aged Brie", TYPE.CHEESE),
ProdToType("Conjured Mana Cake", TYPE.CONJUR),
ProdToType("Elixir of the Mongoose", TYPE.NORMAL),
ProdToType("Backstage passes to a TAFKAL80ETC concert", TYPE.PASSES),
ProdToType("Sulfuras, Hand of Ragnaros", TYPE.AGELES),
ProdToType("+5 Dexterity Vest", TYPE.NORMAL),
)
fun Item.type(): TYPE {
val exact = PRODUCT.filter { it.prodName == name}
if (exact.isNotEmpty()) return exact[0].type
return TYPE.ERROR
}
That stays green. I’ll hold back on the commit until I’m sure I like this.
Now instead of ripping the guts out of the table entry, let’s ask the entry if it matches:
fun Item.type(): TYPE {
val exact = PRODUCT.filter { it.matches(name)}
if (exact.isNotEmpty()) return exact[0].type
return TYPE.ERROR
}
This demands an implementation of matches
:
class ProdToType(val prodName: String, val type: TYPE){
fun matches(name: String): Boolean {
return prodName == name
}
}
Still green. Now I want another class whose match is more capable. Let’s extract an interface, rather than inherit from a concrete class.
interface ProdToType {
val prodName: String
val type: TYPE
fun matches(name: String): Boolean
}
class ExactProdToType(override val prodName: String, override val type: TYPE) : ProdToType {
override fun matches(name: String): Boolean {
return prodName == name
}
}
Still green. Change the table to require the interface, not the Exact guys.
val PRODUCT = listOf<ProdToType>(ExactProdToType("Aged Brie", TYPE.CHEESE),
ExactProdToType("Conjured Mana Cake", TYPE.CONJUR),
ExactProdToType("Elixir of the Mongoose", TYPE.NORMAL),
ExactProdToType("Backstage passes to a TAFKAL80ETC concert", TYPE.PASSES),
ExactProdToType("Sulfuras, Hand of Ragnaros", TYPE.AGELES),
ExactProdToType("+5 Dexterity Vest", TYPE.NORMAL),
)
Now I think I want a test for this next part, just because my existing tests won’t find it.
As soon as I write this, I don’t like it:
class ProdToTypeTest {
@Test
fun `test full match`() {
val p2t = ExactProdToType("thing", TYPE.NORMAL)
assert(p2t.matches("thing"))
}
}
We don’t care if the thing matches. We want the collection of matches back.
We need for our collection to be an object. Wasn’t there a Habit about that?
I’ll write a test for that.
fun `collection returns exact match`() {
val list = listOf(ExactProdToType("thing", TYPE.NORMAL))
val typeConverter = ProductTypeConverter(list)
assertEquals((TYPE.NORMAL), typeConverter.typeOf("thing"))
}
We’re creating a new object here, which means we are creating the protocol here. If we don’t like how it feels, we’ll adjust it. I need a converter object. I’ll build it right here for now.
class ProductTypeConverter(val conversions: List<ProdToType>){
fun typeOf(name: String): TYPE {
val types = conversions.filter { it.matches(name) }.map {it.type}
return if (types.isNotEmpty()) types[0] else TYPE.ERROR
}
}
Test. Good so far. Let’s use it in the actual program.
val PRODUCT = ProductTypeConverter(listOf(
ExactProdToType("Aged Brie", TYPE.CHEESE),
ExactProdToType("Conjured Mana Cake", TYPE.CONJUR),
ExactProdToType("Elixir of the Mongoose", TYPE.NORMAL),
ExactProdToType("Backstage passes to a TAFKAL80ETC concert", TYPE.PASSES),
ExactProdToType("Sulfuras, Hand of Ragnaros", TYPE.AGELES),
ExactProdToType("+5 Dexterity Vest", TYPE.NORMAL),
)
)
fun Item.type(): TYPE {
return PRODUCT.typeOf(name)
}
Test. Green. Note the amazing simplicity of the new type()
function, due to supporting our intention in the new ProductTypeConverter
.
Now for a match test. First drive out a new implementor of ProductToType:
class PartialProdToType(override val prodName: String, override val type: TYPE): ProdToType {
override fun matches(name: String): Boolean {
return name.lowercase().contains(prodName)
}
}
Test. Green. Test partial match in the collection:
@Test
fun `arbitrary conjured item identified`() {
val converter = ProductTypeConverter(listOf(
PartialProdToType("conjured", TYPE.CONJUR)
))
assertEquals(TYPE.CONJUR, converter.typeOf("A COnJuReD Thing"))
}
That passes nicely. Let’s commit: ProductTypeConverter and associated objects do partial match using String.contains.
And now, in the real code, let’s allow for arbitrary conjured items.
val PRODUCT = ProductTypeConverter(listOf(
ExactProdToType("Aged Brie", TYPE.CHEESE),
// ExactProdToType("Conjured Mana Cake", TYPE.CONJUR),
ExactProdToType("Elixir of the Mongoose", TYPE.NORMAL),
ExactProdToType("Backstage passes to a TAFKAL80ETC concert", TYPE.PASSES),
ExactProdToType("Sulfuras, Hand of Ragnaros", TYPE.AGELES),
ExactProdToType("+5 Dexterity Vest", TYPE.NORMAL),
PartialProdToType("conjured", TYPE.CONJUR)
)
)
Note that I turned off the exact match for Mana. Tests all pass.
Now then, if we really want unmatched items to produce the error, we can do this:
val PRODUCT = ProductTypeConverter(listOf(
ExactProdToType("Aged Brie", TYPE.CHEESE),
ExactProdToType("Elixir of the Mongoose", TYPE.NORMAL),
ExactProdToType("Backstage passes to a TAFKAL80ETC concert", TYPE.PASSES),
ExactProdToType("Sulfuras, Hand of Ragnaros", TYPE.AGELES),
ExactProdToType("+5 Dexterity Vest", TYPE.NORMAL),
PartialProdToType("conjured", TYPE.CONJUR),
PartialProdToType("", TYPE.ERROR)
)
)
With that in place, the filter will always return at least one response, so we could remove the check here:
class ProductTypeConverter(val conversions: List<ProdToType>){
fun typeOf(name: String): TYPE {
val types = conversions.filter { it.matches(name) }.map {it.type}
return if (types.isNotEmpty()) types[0] else TYPE.ERROR
}
}
I think I’d leave it.
And we can handle passes as a group. Should we check for “backstage”? For “passes”?
Why not both?
val PRODUCT = ProductTypeConverter(listOf(
ExactProdToType("Aged Brie", TYPE.CHEESE),
ExactProdToType("Elixir of the Mongoose", TYPE.NORMAL),
ExactProdToType("Sulfuras, Hand of Ragnaros", TYPE.AGELES),
ExactProdToType("+5 Dexterity Vest", TYPE.NORMAL),
PartialProdToType("conjured", TYPE.CONJUR),
PartialProdToType("passes", TYPE.PASSES),
PartialProdToType("backstage", TYPE.PASSES),
PartialProdToType("", TYPE.ERROR)
)
)
Now I suspect that Allison and her people are going to want improvements. For example, they might want to put “normal” right in the product name, to trigger TYPE.NORMAL, or some other word of their choice. Oh, and that reminds me, I think we should lower case the comparison string, not just the input, in case someone puts upper case into this table. I’ll rite a test for that.
@Test
fun `case doesn't matter`() {
val p2t = PartialProdToType("cOnJuReD", TYPE.CONJUR)
assert(p2t.matches("Arbitrary CoNjUrEd Item"))
}
Fails, as expected. Code:
class PartialProdToType(override val prodName: String, override val type: TYPE): ProdToType {
override fun matches(name: String): Boolean {
return name.lowercase().contains(prodName.lowercase())
}
}
Green. I wonder if we should do the other match also case insensitive. I find that Kotlin has an equals operator that can compare strings w/o regard to case. Let me write a new test and then put it in place.
class ExactProdToType(override val prodName: String, override val type: TYPE) : ProdToType {
override fun matches(name: String): Boolean {
return prodName.equals(name,ignoreCase = true)
}
}
class PartialProdToType(override val prodName: String, override val type: TYPE): ProdToType {
override fun matches(name: String): Boolean {
return name.contains(prodName, ignoreCase = true)
}
}
Test. Green green green. Commit: ProductToTypeConverters are all case insensitive.
As part of my effort to see how compact the code can be without offending me, I’ve changed these two functions to “expression body”:
class ExactProdToType(override val prodName: String, override val type: TYPE) : ProdToType {
override fun matches(name: String): Boolean
= prodName.equals(name,ignoreCase = true)
}
class PartialProdToType(override val prodName: String, override val type: TYPE): ProdToType {
override fun matches(name: String): Boolean
= name.contains(prodName, ignoreCase = true)
}
I don’t like the abbreviated name “Prod” that I used sometimes, to save typing. And I think the idea is “name to type”, not “product to type”.
I’m going to leave that for another day. We’re green, the code is rather nice, and I’m thinking it’s time for a break. Let’s sum up.
Summary
The enum approach to looking up types was OK but didn’t benefit from the enum. Instead, we converted to a concrete class, and then, quickly, to an interface with two concrete implementors. And we replaced a dumb table with a smart object that converts a name to a type.
Which reminds me of something even better: converting an item to a type.
Let’s build that. We can rely on the existing tests.
class ProductTypeConverter(val conversions: List<ProdToType>){
fun typeOf(name: String): TYPE {
val types = conversions.filter { it.matches(name) }.map {it.type}
return if (types.isNotEmpty()) types[0] else TYPE.ERROR
}
fun typeOf(item: Item): TYPE = typeOf(item.name)
}
And use it:
fun Item.type(): TYPE {
return PRODUCT.typeOf(this)
}
I think I’d like the string typeOf
function to be private. We have two users, both in tests. Change them:
@Test
fun `arbitrary conjured item identified`() {
val converter = ProductTypeConverter(listOf(
PartialProdToType("conjured", TYPE.CONJUR)
))
assertEquals(TYPE.CONJUR,
converter.typeOf(Item("A COnJuReD Thing",10,20)))
}
@Test
fun `collection returns exact match`() {
val list = listOf(ExactProdToType("tHiNg", TYPE.NORMAL))
val typeConverter = ProductTypeConverter(list)
assertEquals((TYPE.NORMAL),
typeConverter.typeOf(Item("ThInG",15,30)))
}
Make string version private. Test. Green. Commit: ProductTypeConverter converts items. string conversion used only internally.
So, as I was saying, we’ve converted the item type lookup to a much more robust scheme that is all packaged in just a few classes. The scheme operates independent of case, and its partial match capability makes it easy to classify objects like passes or conjured items by simply giving each item a suitable name.
Because it’s all packaged inside ProductTypeConverter, we are in a position to make it even more capable while keeping changes local to just the related classes.
I am pleased. Comments welcome, of course. See you next time!
Just in case you’d like to see all the new code:
class ProductTypeConverter(val conversions: List<ProdToType>){
private fun typeOf(name: String): TYPE {
val types = conversions.filter { it.matches(name) }.map {it.type}
return if (types.isNotEmpty()) types[0] else TYPE.ERROR
}
fun typeOf(item: Item): TYPE = typeOf(item.name)
}
enum class TYPE {
AGELES, CHEESE, CONJUR, PASSES, NORMAL, ERROR
}
interface ProdToType {
val prodName: String
val type: TYPE
fun matches(name: String): Boolean
}
class ExactProdToType(override val prodName: String, override val type: TYPE) : ProdToType {
override fun matches(name: String): Boolean = prodName.equals(name,ignoreCase = true)
}
class PartialProdToType(override val prodName: String, override val type: TYPE): ProdToType {
override fun matches(name: String): Boolean = name.contains(prodName, ignoreCase = true)
}
fun Item.type(): TYPE {
return PRODUCT.typeOf(this)
}