Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

class support in Teal #861

Open
ErickProgramer opened this issue Nov 26, 2024 · 4 comments
Open

class support in Teal #861

ErickProgramer opened this issue Nov 26, 2024 · 4 comments
Labels
feature request New feature or request

Comments

@ErickProgramer
Copy link

ErickProgramer commented Nov 26, 2024

It would be great if Teal had built-in support for classes. I know it might be a bit unconventional, but creating classes in Teal with its type-checking system is quite challenging. I often struggle when trying to create classes using external libraries. Here's an example in Lua:

local Person = class()
function Person:new(name, age)
     self.name = name
     self.age = age
end

function Person:__tostring()
     return ("Person(\"%s\", %d)"):format(self.name, self.age)
end

local p = Person("Lua", 33)
print(p)

Adding type annotations to this code would be difficult. Introducing a keyword like class could make this process much easier. For example, in Teal, it could look like this:

local class Person
     name: string
     age: integer

     function __new(name: string, age: integer)
          self.name = name
          self.age = age
     end

     metamethod function __tostring()
          return ("Person(\"%s\", %d)"):format(self.name, self.age)
     end
end

This feature would simplify class creation and provide more robust type checking for classes.

Another issue arises when dealing with class inheritance. Many Lua libraries support inheritance through constructors or arguments, such as in the following example:

local Monster = class("Entity", "Enemy")
function Monster:new(name, health)
     self.name = name
     self.health = health
end

Here, Monster inherits properties and behaviors from both Entity and Enemy. Representing this in Teal is quite cumbersome because it involves carefully managing the types of the parent classes, ensuring proper overrides, and handling shared functionality. This often leads to verbose and complex type annotations, making the code harder to maintain and debug.

With a dedicated class keyword or built-in class system, inheritance could be implemented more intuitively. For example, the equivalent in Teal might look like this:

local Entity = require "game.Entity" -- import from some file that returns the class
local Enemy = require "game.Enemy"

local class Monster : Entity, Enemy
     name: string
     health: integer
     function __new(name: string, health: integer)
          self.name = name
          self.health = health
     end
end

return Monster -- returning the class in the file

This syntax would be much cleaner and easier to understand, while still allowing Teal to enforce type safety. It would also help developers better express relationships between classes, such as parent-child hierarchies or multiple inheritance.

I apologize if this seems like a silly request. If there's an easier or better way to achieve this with the current version of Teal, I’d be grateful if you could provide guidance or examples.

@Andre-LA
Copy link

Andre-LA commented Nov 27, 2024

I suppose this is a duplicate of #97.

I decided to try replicating your example using pure Teal (on 0.24.1) using the interface mechanism, note that I didn't used multiple inheritance, but inheriting from multiple interfaces is possible:

tutorial.md#interfaces


game/entity.tl

-- we need to make an interface that can be inherited by
-- records and interfaces
local interface IEntity
   x: number
   y: number

   -- we must write methods signatures too
   move: function(self, number, number)
   dash: function(self)
end

-- since we can't make method definitions on interfaces, we must use a record for that, which
-- will be returned at the end, thus to export the interface, we make an alias here
local record Entity is IEntity
   -- I found Entity.IEntity pretty redundant to write
   type Interface = IEntity
end

-- for convenience (and optimization), write the Entity metatable here
local entity_mt: metatable<Entity> = {
   __index = Entity
}

-- for initialization, init does the trick, it initializes "an IEntity" (not "the Entity")
function Entity.init(self: IEntity, x: number, y: number)
   self.x = x
   self.y = y
end

-- 100% optional for this example, but I like doing this
function Entity.new(x: number, y: number): Entity
   local entity: Entity = setmetatable({}, entity_mt)
   entity:init(x, y)
   return entity
end

-- methods! defined at the Entity record
function Entity:move(dx: number, dy: number)
   self.x = self.x + dx
   self.y = self.y + dy
end

function Entity:dash()
   self:move(50, 0)
end

-- note that you can also return interfaces as a module too, but since we
-- can't define methods on interfaces (since they are abstract, that is, not concrete like records),
-- we export the Entity record, with the Interface alias inside
return Entity

game/enemy.tl

-- require the Entity record, if this was the interface, then
-- it would be `local type` instead of just `local`
local Entity = require 'game.entity'

-- same thing, we define the interface that can be inherited, then the record;
-- note how IEnemy inherits Entity's interface;
-- btw, types can also be defined inside interfaces;
local interface IEnemy is Entity.Interface
   enum Kind
      'angry'
      'faster'
   end

   kind: Kind
end

local record Enemy is IEnemy
   type Interface = IEnemy
end

-- By applying Entity on Enemy's __index metafield directly, inheritance works
-- at run-time, because Enemy values will have it's __index to Enemy,
-- which does have an __index to Entity, in other words:
-- `value: Enemy` --> `Enemy` --> `Entity`
setmetatable(Enemy, { __index = Entity })

local enemy_mt: metatable<Enemy> = {
   __index = Enemy
}

-- Same idea, re-using original Entity.init
function Enemy.init(self: IEnemy, x: number, y: integer, kind: Enemy.Kind)
   Entity.init(self, x, y)
   self.kind = kind
end

function Enemy.new(x: number, y: integer, kind: Enemy.Kind): Enemy
   local enemy: Enemy = setmetatable({}, enemy_mt)
   enemy:init(x, y, kind)
   return enemy
end

-- override Enemy.dash by defining dash here, this way
-- enemy_value:dash() will access Enemy.dash instead of Entity.dash
function Enemy:dash()
   -- in this case, Enemy.dash does double dash if it's the 'faster' kind

   -- using the `as` keyword for base methods are necessary
   Entity.dash(self as Entity)

   if self.kind == 'faster' then
      Entity.dash(self as Entity)
   end
end

return Enemy

game/monster.tl

-- Same concepts here :)

local Enemy = require 'game.enemy'

local interface IMonster is Enemy.Interface
   name: string
   health: integer

   hit: function(self, integer)
end

local record Monster is IMonster
   type Interface = IMonster
end
setmetatable(Monster, { __index = Enemy })

-- private methods are just local functions with a self parameter
local function monster_tostring(self: Monster): string
   return string.format(
      "Monster { x = %d, y = %s, kind = '%s', name = '%s', health = %d }",
      self.x, self.y, self.kind, self.name, self.health
   )
end

local monster_mt: metatable<Monster> = {
   __index = Monster,
   __tostring = monster_tostring,
}

function Monster.init(self: IMonster, x: number, y: integer, name: string, kind?: Enemy.Kind)
   Enemy.init(self, x, y, kind or 'angry')
   self.name = name
   self.health = 10
end

function Monster:hit(damage: integer)
   self.health = self.health - damage
end

function Monster.new(x: number, y: integer, name: string, kind?: Enemy.Kind): Monster
   local monster: Monster = setmetatable({}, monster_mt)
   monster:init(x, y, name, kind)
   return monster
end

return Monster

test.tl

-- Let's test!

-- require the Monster
local Monster = require 'game.monster'

-- create a new monster, with a convenient new function :)
local monster = Monster.new(10, 20, 'Bob the monster')

-- move and hit, the typechecker accepts these at compile-time because they're
-- defined on the interfaces, and bound at run-time thanks to __index metafields
monster:move(10, 20) -- from Entity.move
monster:hit(3) -- from Monster.hit

print(monster)

monster:dash() -- from Enemy.dash instead of Entity.dash

print(monster)

local faster_monster = Monster.new(0, 0, 'Billy the fast monster', 'faster')

faster_monster:dash() -- double dash from 0 to 100 on x field
print(faster_monster)

Output:

Monster { x = 20, y = 40, kind = 'angry', name = 'Bob the monster', health = 7 }
Monster { x = 70, y = 40, kind = 'angry', name = 'Bob the monster', health = 7 }
Monster { x = 100, y = 0, kind = 'faster', name = 'Billy the fast monster', health = 10 }

And that's it! Maybe an utility library could be made with generics to make things simpler, but this way both inheritance and method overriding works.

@Andre-LA
Copy link

By the way, @hishamhm, is this as requirement on Enemy.dash expected?

self on Enemy.dash is an Enemy, which is an IEnemy, which is an IEntity.

self on Entity.dash is an Entity, which is also an IEntity; even if I use Entity.dash(self: IEntity) the as use is still required.

@ErickProgramer
Copy link
Author

ErickProgramer commented Nov 27, 2024

@Andre-LA Sounds great, how would it be with a library? Using generics e.g.

@hishamhm hishamhm added the feature request New feature or request label Dec 18, 2024
@hishamhm
Copy link
Member

hishamhm commented Dec 18, 2024

I suppose this is a duplicate of #97.

Yes, it is.

By the way, @hishamhm, is this as requirement on Enemy.dash expected?

@Andre-LA I'll need to go through it carefully -- I'll keep this open until I get the chance to give this a proper look!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature request New feature or request
Projects
None yet
Development

No branches or pull requests

3 participants