顯示具有 Rustlang 標籤的文章。 顯示所有文章
顯示具有 Rustlang 標籤的文章。 顯示所有文章

2020年8月9日 星期日

使用 Amethyst Engine 實作小行星遊戲 - 9 UI

現在讓我們來建 UI,我覺得建 UI 是目前掌握度比較低的部分,我也在想怎麼做才是對的。
目標是加上一個計分用的文字:
首先是 resource 的部分,建立 FontRes 來保存載入的字型檔,要建立文字的時候只要取用這個資源即可:
pub struct FontRes {
  pub font : FontHandle
}

impl FontRes {
  pub fn initialize(world: &mut World) {
    let font = world.read_resource::<Loader>().load(
      "font/square.ttf",
      TtfFormat,
      (),
      &world.read_resource(),
    );
    world.insert(
      FontRes { font : font }
    );
  }
  pub fn font(&self) -> FontHandle {
    self.font.clone()
  }
}

這部分跟載入 sprite 沒有差很多,就不贅述;另外我們再實作一個 ScoreRes,用來儲存目前的分數跟儲存 UI 文字的 Entity。
pub struct ScoreRes {
  pub score: i32,
  pub text: Entity,
}

impl ScoreRes {
  pub fn initialize(world: &mut World) {
    let font = world.read_resource::<FontRes>().font();
    let score_transform = UiTransform::new(
      "score".to_string(), Anchor::TopRight, Anchor::MiddleLeft,
      -220., -60., 1., 200., 50.);
    let text = world
      .create_entity()
      .with(score_transform)
      .with(UiText::new(font, "0".to_string(), [0.,0.,0.,1.], 50.))
      .build();

    world.insert(ScoreRes {
      score: 0,
      text: text
    });
  }
}

簡單來說 UI 的文字,自然也是一個 entity,裡面有兩個 components 分別是位移 UiTransform 跟 UiText;UiTransform 會需要幾個參數:
  • id :幫助辨識是哪個 Ui 元件。
  • anchor、pivot:Ui 元素位在 parent 的哪個方位、位在自己的哪個方位,可以用九宮格的方式來指定。
  • 後面五個數字則是指定 x, y, z, width, height。
這裡我們把顯示擊落數的文字定在畫面右上角,x y 的位移值剛好補償它的寬度跟高度。
UiText 需要帶入剛剛讀進來的字型,後面指定文字內容、顏色跟尺寸。

要修改文字的話,我們稍微修改一下先前實作的 Collision System,要新增存取 ScoreRes 這個 resource,另外記得上面所說 UI 文字也是 entity,文字的資訊是保存在 UiText 這個 Component 裡面,所以要改文字,我們一併要存取 UiText 這個 Component:
type SystemData = (
  WriteExpect<'s, ScoreRes>
  WriteStorage<'s, UiText>
)
fn run(&mut self,
          (mut scoretexts,
           mut uitext): Self::SystemData) {
  // change score
  scoretexts.score = scoretexts.score + 1;
  if let Some(text) = uitext.get_mut(scoretexts.text) {
    text.text = scoretexts.score.to_string()
  }
}
先直接修改 resource 裡面儲存的 score 的值,再來是用 Component uitext 去取出 resource 裡記錄的 text entity,這樣拿出來的就是這個 entity 所含的 UiText Component,這時候才能去修改它的 text 屬性。
上面這段 code 我放在處理碰撞的地方,只要有雷射砲跟小行星碰撞就會執行一次,真的要分得非常詳細,可以把這段移到獨立的系統中,比較不會亂掉。

你可能會問,這樣不就…讓 ScoreRes 這個 resource 的實作內容給暴露出來了,不能把 UiText 跟 score 等等的好好好封裝到一個 struct 裡面,並公開介面如 setText 讓外部使用嗎?沒錯當初我也是想要這樣設計,只不過到目前為止都沒有成功過 (._.),目前只能將就一下用這樣難看的寫法,畢竟連官方的範例都是這樣教……如果有大大知道的話也請不吝賜教。

2020年7月10日 星期五

使用 Amethyst Engine 實作小行星遊戲 - 8 使用 ncollide2d 實作碰撞

碰撞偵測應該也是許多遊戲內必要的元素之一,比如說我們的打小行星遊戲,就需要偵測雷射砲跟小行星的碰撞,以及小行星和太空船的碰撞。
簡單一點的土砲法,是用 for loop 把小行星跟電射砲的座標收集起來,太近的兩者把 entity 刪掉就行了;但我們畢竟身為專業的遊戲設計(才怪),用土砲法就太遜了,這裡我們用同樣是 rust 寫的 ncollide2d 套件來實作碰撞偵測。

因為 amethyst 內部包了一層 nalgebra 的關係,我們的 ncollide2d 用的版本必須是 0.21 版,我覺得這個問題挺…麻煩的,要自己去搜 amethyst 相依的 nalgebra 到底是哪個版本,然後對應回 ncollide2d 對應的版本。
為了識別每一個物件的屬性,我們在各個 entity 上面都加上一個新的 component:Collider,內含一個 enum 屬性;在每個 entity 上面都要附上這個 component 作為識別。
pub struct Collider {
  pub typ: ColliderType
}

pub enum ColliderType {
  Ship,
  Bullet,
  Asteroid,
}
從我們之前的教學文學到的 rule of thumb:要改變行為,就是加一個系統。
use ncollide2d::{
  bounding_volume,
  broad_phase::{DBVTBroadPhase, BroadPhase, BroadPhaseInterferenceHandler}
};
引入 ncollide2d 相關的模組。

#[derive(SystemDesc)]
pub struct CollisionSystem;

impl<'s> System<'s> for CollisionSystem {
  type SystemData = (
    Entities<'s>,
    ReadStorage<'s, Collider>,
    ReadStorage<'s, Ship>,
    ReadStorage<'s, Transform>,
  );
這段就只是宣告一下 system,因為有生命周期上色很麻煩所以獨立出一段來。
fn run(&mut self,
           (entities, colliders, ships, transforms): Self::SystemData) {

  // collect collider
  let mut broad_phase = DBVTBroadPhase::new(0f32);
  let mut handler = BulletAsteroidHandler::new();
  for (e, collider, _, transform) in (&entities, &colliders, !&ships, &transforms).join()  {
    let pos = transform.translation();
    let pos = Isometry2::new(Vector2::new(pos.x, pos.y), zero());
    let vol = bounding_volume::bounding_sphere( &Ball::new(5.0), &pos );
    broad_phase.create_proxy(vol, (collider.typ, e));
  }
  broad_phase.update(&mut handler);
  
新增一個 CollisionSystem,要動到有 Collider component 的 entity,讀位置需要 Transform。
上面顯示了 ncollide2d 提供的 DBVTBroadPhase 的碰撞偵測,它會把輸入的資料依照空間位置分到樹狀的結構上,更有效率的去偵測碰撞。
用 for loop 把所有非 ship 的 collider 取出來,從 transform 建立物體位置,再建立 ncollide2d 提供圓形 bounding_volume,這是 DBVTBroadPhase 偵測用的 key。
我們用 (ColliderType, Entity) 作為 value,ColliderType 是用來識別碰撞的物體型別,我們只希望偵測子彈跟小行星的碰撞,忽略其他像小行星自己的碰撞;Entity 則是在碰撞發生的時候,能夠追溯到是哪個 entity 發生碰撞。
最後只要呼叫 DBVTBroadPhase 的 update 並代入實作 BroadPhaseInterferenceHandler 的 struct 即可。下面就來實作 handler:
struct BulletAsteroidHandler {
  collide_entity: Vec<Entity>,
}

impl BulletAsteroidHandler {
  pub fn new() -> Self {
    Self {
      collide_entity: vec![],
    }
  }
}

type ColliderEntity = (ColliderType, Entity);
定義一下 type alias 寫起來比較方便:
impl BroadPhaseInterferenceHandler<ColliderEntity> for BulletAsteroidHandler {
  fn is_interference_allowed(&mut self, a: &ColliderEntity, b: &ColliderEntity) -> bool {
    a.0 != b.0
  }
  fn interference_started(&mut self, a: &ColliderEntity, b: &ColliderEntity) {
    self.collide_entity.push(a.1);
    self.collide_entity.push(b.1);
  }
  fn interference_stopped(&mut self, _a: &ColliderEntity, _b: &ColliderEntity) {}
}
is_interference_allowed 用來判斷這兩個物體能不能發生碰撞,這裡要求它們的 ColliderType 要不一樣;interference_started 則是碰撞發生時的處理,把兩個 entity 存起來;interference_stopped 處理碰撞結束的行為,留空就好

上面呼叫完 broad_phase.update(&mut handler) 之後,從 handler.collide_entity 就能拿到碰撞的 bullet 跟 asteroid,直接刪掉 entity 即可。
for e in handler.collide_entity {
  if let Err(e) = entities.delete(e) {
    error!("Failed to destroy collide entity: {}", e)
  }
}
這篇其實非常偷懶了,目前至少有下面兩點可以改進:
  • 不用在 system 裡面產生新的 DBVTBroadPhase 並重新插入 proxy,應該把碰撞偵測當成一個 resource,每次只要更新 DBVTBroadPhase 內記錄的位置,速度應該會比我們這樣從頭打造一個快。
  • 將 bounding_volume 存在 Collider 裡面,而不是每個東西都是單一尺寸的圓形,這樣也不用每次都重新生成新的 bounding_volume,從 Collider 裡面拿就可以了。
不過在我們這個小遊戲上還不需要在意這個,做完這一大步,現在遊戲已經有個可以玩的樣子了……雖然我立刻發現它難度有點太高了,我通常撐不到十秒,放不出 C8763,不過這只需要我們動一些參數,最後再來調整就好了。

2020年7月5日 星期日

使用 Amethyst Engine 實作小行星遊戲 - 7 亂數

亂數在遊戲中也是個舉足輕重的腳角,少了亂數的遊戲就像沒加珍珠的奶茶(?,讓玩家食之無味;這章我們會加上亂數,以及產生小行星的 system。

亂數直接用 rust 官方的 rand 模組,為了把它嵌入 ECS 裡面,可以觀察一下亂數模組會有什麼特性:大家使用一個亂數模組而不是大家都有一份,符合這個特性的就是 resource 了。
在 resources.rs 加上一個新的 struct:
pub struct RandomGen;

impl RandomGen {
  pub fn next_f32(&self) -> f32 {
    use rand::Rng;
    rand::thread_rng().gen::<f32>()
  }
}
它把 rand 模組給包起來,在內部呼叫 thread_rng().gen 產生 f32 的亂數,在載入資源的時候我們一併插入這個 resource,AsteroidRes 請仿造 ShipRes 跟 BulletRes 寫一份,這裡應該可以再把三個資源共用的部分抽出來,不過因為是範例 project 我就沒這麼做:
ShipRes::initialize(world);
BulletRes::initialize(world);
AsteroidRes::initialize(world);
world.insert(RandomGen);
ECS 的道理,要加上新的行為就是寫一個新的系統,我們把和生成小行星有關的設定都放在這個系統裡面(雷射槍的冷卻時間和太空船是綁在一起的,因此放在 Ship component 裡面):
#[derive(SystemDesc)]
pub struct SpawnAsteroidSystem {
  pub time_to_spawn: f32,
  pub max_velocity: f32,
  pub max_rotation: f32,
  pub distance_to_ship: f32,
  pub average_spawn_time: f32,
}
實作上,entities、LazyUpdate 是產生新物件必備;Ship、Transform 用來取得船的位置,免得小行星直接出現在太空船的旁邊;AsteroidRes、RandomGen 則是需要的資源:
impl<'s> System<'s> for SpawnAsteroidSystem {
  type SystemData = (
    Entities<'s>,
    ReadStorage<'s, Ship>,
    ReadStorage<'s, Transform>,
    ReadExpect<'s, AsteroidRes>,
    ReadExpect<'s, RandomGen>,
    Read<'s, LazyUpdate>,
    Read<'s, Time>,
  );
一開始就跟 ShipControlSystem 一樣,從 time.delta_seconds 取得秒數,去扣掉 system 的 time_to_spawn,一但低於零就會生成一顆小行星,並將 time_to_spawn 設定回 average_spawn_time。
let mut create_point: Vector3<f32> = zero();
// generate creation point
loop {
  create_point.x = rand.next_f32() * ARENA_WIDTH;
  create_point.y = rand.next_f32() * ARENA_HEIGHT;
  if (ship_translation-create_point).norm() > self.distance_to_ship {
    break;
  }
}
transform.set_translation_x(create_point.x);
transform.set_translation_y(create_point.y);
生成點的位置我用了偷懶的方式,一般在 system 內最好不要有這種執行時間不確定的東西,如果一不小心 distance_to_ship 設太大,可能會讓 system 進到無窮迴圈把整個遊戲卡住,比較好的做法應該是從 distance_to_ship,亂數產生距離跟方位角就可以了。
let e = entities.create();
lazy.insert(e, Asteroid {} );
lazy.insert(e, transform);
lazy.insert(e, physical);
lazy.insert(e, asteroidres.sprite_render());
所有的 component 都準備好之後,一樣透過 LazyUpdate 把 component 塞進 entity 裡面即可,現在我們的遊戲應該有個樣子,平均每兩秒生成一顆小行星,按空白可以射出雷射。
我們還沒實作碰撞,所以自然還沒有任何打爆小行星的行為,雷射打到小行星也只是無視的飛過去,下一章我們就進到遊戲除了亂數之外另一個必備成員:碰撞。

2020年7月3日 星期五

使用 Amethyst Engine 實作小行星遊戲 - 6 刪除物體

一般來說這種射小行星的遊戲,都會有一個莫名的設定,那就是太空船和小行星來到邊界的時候,會從螢幕的對面出現,就好像畫面是一個攤平的球體一樣。
估且不論這個設定合不合理,我們就來實作一下:

託 amethyst 之福,所謂實作就是:加一個新的 system,這裡叫它 BoundarySystem:
#[derive(SystemDesc)]
pub struct BoundarySystem;

impl<'s> System<'s> for BoundarySystem {
  type SystemData = (
    WriteStorage<'s, Transform>,
    ReadStorage<'s, Physical>,
    ReadStorage<'s, Bullet>,
    Entities<'s>,
  );
  
這個 system 要改動的是所有含 Physical Component 的 entity 的 Transform 屬性,設定 Transform 為 Write;要刪除 entity 因此需要 Entities。
for (_, _, transform) in (&physicals, !&bullets, &mut transforms).join() {
  let obj_x = transform.translation().x;
  let obj_y = transform.translation().y;
  if obj_x < 0.0 {
    transform.set_translation_x(ARENA_WIDTH-0.5);
  } else if obj_x > ARENA_WIDTH {
    transform.set_translation_x(0.5);
  }
 
  if obj_y < 0.0 {
    transform.set_translation_y(ARENA_HEIGHT-0.5);
  } else if obj_y > ARENA_HEIGHT {
    transform.set_translation_y(0.5);
  }
}
之所以要引入 bullets,是因為我們希望把含有 bullet component 一出畫面就直接消失,需要分出來特別對待。在 for loop join 的地方,可以列出一排 component,取出「包含所有列出 component 的 entity」,也可以用 Logical negation operator !,取出「不包含這個 component 的 entity」,就可以把 bullet 給排除在外。
for (e, _, transform) in (&*entities, &bullets, &mut transforms).join() {
  let x = transform.translation().x;
  let y = transform.translation().y;
  if x < 0.0 || y < 0.0 || x > ARENA_WIDTH || y > ARENA_HEIGHT {
    if let Err(e) = entities.delete(e) {
      error!("Failed to destroy entity: {}", e)
    }
    continue;
  }
}
另外一個迴圈處理 bullet,這次我們用 &*entities 拿到對應的 entity,在超出螢幕範圍的時候,呼叫 entities.delete(e) 把 entity 給刪除。

其實刪除 entity 就是如此簡單,單獨成一章實在有點不平衡XD。

2020年7月1日 星期三

使用 Amethyst Engine 實作小行星遊戲 - 5 生成物體

其實這章才是真的讓這堆教學跟官方 pong 不一樣的地方,在遊戲內生成 entity;官方的 pong 就是生出兩塊板子一顆球,球跑到場外就計分然後把球放回場中間,完全不會新增/刪除 entity。

在這之前我們先做一點 refactor,之前我們在 states.rs 裡寫了一個函式:load_sprite_sheet 用來載入 sprite 資源,但這個資源沒向 world 註冊,在 system 裡面會無法使用。
先來改善這點,把 Handle<SpriteSheet> 包進 struct,放到另一個 textures.rs 檔案裡:
pub struct SpriteStore {
  handle: Handle<SpriteSheet>
}
這個 struct 提供兩個函式:
  • from_path:利用 load_sprite_sheet 取得 Handle<SpriteSheet>,建構 SpriteStore
  • sprite_render:給定一個 frame id ,從 handle 建構出 SpriteRender。
另外一個新檔案則是 resources.rs:
pub struct ShipRes {
  pub sprite_store: SpriteStore,
}

impl ShipRes {
  pub fn initialize(world: &mut World) {
    let sprite_store = SpriteStore::from_path(world, "ship");
    world.insert(
      ShipRes { sprite_store : sprite_store }
    );
  }

  pub fn sprite_render(&self) -> SpriteRender {
    self.sprite_store.sprite_render(0)
  }
}
用一個 struct 再把 sprite_store 包起來,我在名字後綴 Res 表示 Resource,用來跟 component 的 ship 做區隔,不然到處都是 Ship 很麻煩。
這個 struct initialize 拿到 SpriteStore 後,會呼叫 world.insert() 把自己這個資源註冊到 world 裡面,使用同樣的結構,我們可以另外生成資源 BulletRes 用來生成子彈。
記得在產生 game data 的時候,呼叫資源 struct 的 initialize 函式。

現在我們可以擴充我們的 ShipControlSystem 了:
impl<'s> System<'s> for ShipControlSystem {
  type SystemData = (
    WriteStorage<'s, Physical>,
    WriteStorage<'s, Ship>,
    ReadStorage<'s, Transform>,
    ReadExpect<'s, BulletRes>,
    Entities<'s>,
    Read<'s, LazyUpdate>,
    Read<'s, InputHandler::<StringBindings>>,
    Read<'s, Time>,
  );
擴充後的 SystemData 大幅增加取用的類別:
  • BulletRes 是 Resource,因為沒有 default 我們使用 ReadExpect 來讀取。
  • Entities<'s> 要新增/刪除 entity 必要的
  • LazyUpdate 是 amethyst 提供的一個方式,如果一條更新會動到很多的資源,可以先用 LazyUpdate 的方式記下來之後一次處理。
  • Time 跟 Ship 的寫入權限是要處理冷卻時間用的。
下面是在 ShipControlSystem 和射出子彈有關的原始碼:
fn run(&mut self,
          (mut physicals,
           mut ships,
           transforms,
           bullet_resources,
           entities,
           lazy,
           input,
           time): Self::SystemData) {
  for (physical, ship, transform) in (&mut physicals, &mut ships, &transforms).join() {
    let shoot = input.action_is_down("shoot").unwrap_or(false);
    // handle shoot
    if ship.reload_timer <= 0.0f32 {
      if shoot {
        ship.reload_timer = ship.time_to_reload;

        let bullet_transform = transform.clone();
        let velocity = transform.rotation() * Vector3::y() * 150f32;
        let velocity = physical.velocity + Vector2::new(velocity.x, velocity.y);
        let bullet_physical = Physical {
          velocity: velocity,
          max_velocity: 200f32,
          rotation: 0f32,
        };

        let e = entities.create();
        lazy.insert(e, Bullet {} );
        lazy.insert(e, bullet_transform);
        lazy.insert(e, bullet_physical);
        lazy.insert(e, bullet_resources.sprite_render());
      }
    } else {
      ship.reload_timer = (ship.reload_timer - delta).max(0.0f32);
    }
  }
}
首先,在遊戲裡面會重複的只有 component,所以需要用 for loop 配 join 解開的也只有用 ReadStorage/WriteStorage 拿進來的 component,其他都不用。
接著我們會去檢查 ship component 儲存的 reload_timer,依照 Time delta 減少,變為 0 就可以射擊,一但射擊了就會把 reload_timer 加一個冷卻時間。
後面會用 ship 的 transform 算出子彈速度,產生子彈用的 physical component。
產生 entity 其實很簡單,呼叫 entities.create() ,再用 LazyUpdate insert,往這個 entity 裡面塞 component。
bullet_transform 跟 physical 都是我們剛產生的;resource 一定要先註冊過之後,才能像這樣用 ReadExpect 拿出來用,我們直接呼叫 sprite_render 函式,拿到可顯示的 SpriteRender component,一樣塞進去就行了。

用 system 產生 entity 就介紹到這邊,其實沒有很難;大家應該更能感受到 ECS 系統的精神,Entity - 我們在螢幕上面看到的船 - 其實就是個空殼。
  • 它為什麼可以顯示東西?我們幫他加上一個 SpriteRender component。
  • 它為什麼有速度可以旋轉?它有 physical component。
  • 要變化位置?加上 transform component ,讀取 physical 來更新它。
  • 要設定加速度的值?把加速度存在 ship component 裡。
沒錯,所有的東西都是 component 達成的,entity 一點用也沒有,隨之而來的就是彈性,還要加上碰撞?在需要碰撞的三個東西:船、小行星、雷射加上一個新的 Collider component ,再寫新的系統處理它就可以了,舊有的程式碼完全不需要改動,這大概是 ECS 系統帶來的最大好處了。

2020年6月29日 星期一

使用 Amethyst Engine 實作小行星遊戲 - 4 移動物體

上一章連接輸入的部分,我們完成了第一個 system 的設計,當然這個系統什麼事都沒做,這章我們就來把輸入接到真正的變化上。

一開始讓我們在 component.rs 裡面新增一個 component physical:
use amethyst::{
  core::math::Vector2,
};

pub struct Physical {
  // velocity, [vx, vy]
  pub velocity: Vector2<f32>,
  // maximum velocity (units / s)
  pub max_velocity: f32,
  // rotation (rad / s)
  pub rotation: f32,
}

impl Component for Physical {
  type Storage = DenseVecStorage<Self>;
}

直覺上來想,可能會覺得我們之前產生的 Ship component 裡面應該要有 velocity 屬性,system 會讀取 velocity 去更新太空船的位置 - 也就是 transform。
但是不對,因為畫面上會動的東西不止有 Ship,之後會新增的子彈跟小行星都是會動的,而實作<會動>都是共同的,寫在 Ship 內的性質無法共用,所以我們直接新增一個 physical component 記錄速度和旋轉量,想要動的東西只要加上 physical component 就行了,程式碼共用的部分可以很漂亮的抽出來。
這裡我們直接使用 nalgebra 的 vector2 來代表速度,amethyst 裡面有包一包 nalgebra 並且重命名為 core::math,版本是 0.19.0,這個在我們後面引入 ncollide2d 的時候會帶來麻煩,不過這裡就先用吧。

state.rs 裡面,在 initialize_ship 函式幫產生的 entity 加上 Physical component:
use crate::components::{Ship, Physical};

world
  .create_entity()
  .with(transform)
  .with(sprite_render.clone())
  .with(Ship::new())
  .with(Physical {
    velocity: zero(),
    max_velocity: 10.0,
    rotation: 0.0
  })
  .build();
現在來修改 system,先新增一個操作 Physical component 的 system:
#[derive(SystemDesc)]
pub struct PhysicalSystem;

impl<'s> System<'s> for PhysicalSystem {
  type SystemData = (
    ReadStorage<'s, Physical>,
    WriteStorage<'s, Transform>,
    Read<'s, Time>,
  );

  fn run(&mut self,
    (physicals,
     mut transforms,
     time): Self::SystemData) {
    let delta = time.delta_seconds();
    for (physical, transform) in (&physicals, &mut transforms).join() {
      let movement = physical.velocity * delta;
      let rotation = physical.rotation * delta;
      transform.prepend_translation(Vector3::new(movement.x, movement.y, 0.0));
      transform.rotate_2d(rotation);
    }
  }
}
每個系統都要定義 SystemData,也就是它需要存取哪些資源:無非就是讀寫 component、新增/刪除 entity 還有讀取 world 內存的 resource,PhysicalSystem 會需要去讀 Physical component、系統提供的 Time resource、寫入 transform component。
在讀取和寫入有不同的方式,可以參考文件 system 章節,大致整理起來是:
  • Read<'s, Resource>、Write<'s, Resource>:取得唯讀/可讀寫的 Resource,這個是保證不會失敗的取得資源,如果失敗的話會直接給你一個 Default::default() 的版本。
  • ReadExpect<'s, Resource>、WriteExpect<'s, Resource>:同上,但這個適用在沒有實作 Default::default() 的資源上。
  • ReadStorage<'s, Component>、WriteStorage<'s, Component>:取得唯讀/可讀寫的 Component 參考。
  • Entities<'s>:創造或刪除 entity 用。
實作 system 只要實作一個 run 函式,這裡有兩種寫法,一種如上面所示,在參數階段就把 SystemData 解開來;另一種則是在函數內解開:
fn run(&mut self, data: Self::SystemData) {
  let (physicals,
        mut transforms,
        time) = data;
  // ...
}
兩種對編譯器來說應該是一樣的,所以選一個喜歡的就可以了,但記得一個原則,一定要把每一行都分開寫,不要擠在一行:
let (physicals, mut transforms, time) = data;
這是因為我們的 system 是會長大的,哪天要加一個新的 component,直接加一行會比在一行裡面找到正確的位置還要簡單。

從 SystemData 拿到的,會是這個遊戲裡「所有有這個 component 的資料」,這裡會收集到所有有 Physical component 的 entity;所以我們用 for loop ,搭配 join() 把資料展開來。
只要是 component 展開這步幾乎是必要的,我個人是建議從 system data 裡解出來的資料,一律加 s 用複數,用 for 解出來再變單數,變數名詞選同一個,比較不會去考慮哪個是哪個。
再來就是位移跟旋轉的實作,從 Time 這個 resource 裡面,我們可以拿到和上一個 frame 之間的時間差,配合 physical 裡面記錄的速度和角速度算出位移量,並更新到 transform 就行了。

上一篇的 ShipControlSystem 也要修改,它會從 ship 定義的加速度算出速度的變化值,修改到 physical 速度內,這裡揭示了 for loop 配 join 的用法,從 ReadStorage<'s, Physical> 拿到的,是所有「有 physical component」,只想要改 ship,只要把 physicals、ships 一起放進 join 裡面,就會只拿出同時有 physical 和 ship component 的 entity 了:
let delta = time.delta_seconds();
for (physical, ship, transform) in (&mut physicals, &ships, &transforms).join() {
  let acceleration = input.axis_value("accelerate");
  let rotate = input.axis_value("rotate");

  // handle acceleration -> velocity
  let acc = acceleration.unwrap_or_default();
  let added = Vector3::y() * delta * acc * ship.acceleration;
  let added = transform.rotation() * added;
  physical.velocity += Vector2::new(added.x, added.y);

  let magnitude = physical.velocity.magnitude();
  if magnitude > physical.max_velocity {
    physical.velocity *= physical.max_velocity / magnitude;
  }
最後一步,和上一篇一樣把我們的 system 註冊到 game data 裡面
use crate::system::{ShipControlSystem, PhysicalSystem};

let game_data = GameDataBuilder::default()
  // Render, transform bundle here ...
  .with(ShipControlSystem, "ship_control_system", &["input_system"])
  .with(PhysicalSystem, "physical_system", &["ship_control_system"]);

註冊 system 的 with 是可以寫明相依關係的,三個參數分別是 system struct,system name 和 dependencies list,我們這裡的寫法 input_system(這個名字應是預設的)、ShipControlSystem、PhysicalSystem 會依序執行。

使用 Amethyst Engine 實作小行星遊戲 - 3 連接輸入

這章有點短,但因為接了鍵盤輸入又要介紹處理輸入的 system 的話,篇幅又太長了,以每章都介紹同樣內容的原則獨立出來;對應為 pong tutorial 的第三章前半部

我們上一章已經寫了幾個 entity 跟 component,這章要開始進到 system,用來操作 entity 跟 component 的內容,在每個 frame system 都會叫起來執行一次,不做事或者做點變動。
要接使用者的輸入,我們在 config 下面準備第二個設定檔:config/input.ron。
(
  axes: {
    "rotate": Emulated( neg: Key(Left), pos: Key(Right) ),
    "accelerate": Emulated( neg: Key(Down), pos: Key(Up) ),
  },
  actions: {
    "shoot": [[Key(Space)]]
  },
)

amethyst 中輸入有兩種形式:axes 和 actions
  • axes 模擬的是兩個不同方向的操作,如果是搖桿的話應該能讀到類比的輸入,鍵盤則是兩個按鍵的互斥的輸入,兩個按鍵只會判定有一個被按下
  • actions 表示數位的輸入,只有按下跟放開兩種狀態。
我們這裡創造兩組 axes 輸入命名為 rotate 跟 accelerate,綁定方向鍵;一個 action 輸入接空白鍵。
可以綁定的對象當然不限於鍵盤,比如說 axes 就能綁定鍵盤、控制器(也許是搖桿)、滑鼠、滑鼠滾輪等輸入;Key 能綁定什麼按鍵則請參考文件

要接輸入,我們要對遊戲的 main.rs 作些修改,加上新的 input_bundle:
use amethyst::input::{InputBundle, StringBindings},

let input_config_path = config_dir.join("input.ron");
let input_bundle = InputBundle::<StringBindings>::new()
       .with_bindings_from_file(input_config_path)?;

let game_data = GameDataBuilder::default()
    // Render, transform bundle here ...
    .with_bundle(input_bundle)?
我們產生一個 InputBundle,並用字串來作為讀取 ron 檔案時的 key:也就是上面我們寫的 "rotate", "accelerate" 等。

這裡我們準備好寫我們第一個 system 了,這裡我們先把所有的 system 都塞在一個 system.rs 裡面,如果分割得更清楚的話,也可以改用 system 的 module 把各系統分到不同的檔案裡面。
system.rs 的內容如下:
use amethyst::{
  core::{Transform},
  derive::{SystemDesc},
  ecs::{Join, ReadStorage, WriteStorage, System, SystemData, Read},
  input::{InputHandler, StringBindings},
};

use crate::component::{Ship};

#[derive(SystemDesc)]
pub struct ShipControlSystem;

impl<'s> System<'s> for ShipControlSystem {
  type SystemData = (
    WriteStorage<'s, Transform>,
    ReadStorage<'s, Ship>,
    Read<'s, InputHandler::<StringBindings>>,
  );

  fn run(&mut self,
    (mut transforms,
     ships,
     input): Self::SystemData) {
    for (_ship, _transform) in (&ships, &mut transforms).join() {
      let rotate = input.axis_value("rotate");
      if let Some(rotate) = rotate {
        println!("{}", rotate);
      }
    }
  }
}
這個 system 很簡單,去讀 input 的值然後印出來,同樣的 system 也只是一個 struct,我們要實作 System<'s> trait ,裡面帶了 run 這個函式,amethyst 引擎會在每個 frame 呼叫各 system 的 run;實作 system 時也要指定對應的 SystemData,SystemData 的內容對應 world 裡面儲存的 entity、component 或是 resource;以我們這個系統為例,它會去修改 transform component、讀取 ship component、讀取使用者輸入。
在 run 裡,for loop 在下一章會更深入的介紹,這裡我們就是去讀取 input 的 axis_value,指定的 key 是 "rotate",得到對應左右鍵的輸入值,rotate 的值會是 Some(-1), Some(0), Some(1) 其中一個。

最後一步我們要把系統註冊到 game data,在 main.rs 生成 game_data 的地方,加上新的 System
use crate::system::{ShipControlSystem};

let game_data = GameDataBuilder::default()
    // Render, transform bundle here ...
    .with_bundle(input_bundle)?
    .with(ShipControlSystem, "ship_control_system", &["input_system"]);
執行遊戲的時候試按左右鍵就能看到輸出值的變化了。

2020年6月26日 星期五

使用 Amethyst Engine 實作小行星遊戲 - 2 讀入資源

畫東西應該是遊戲最基本的功能,除非你是要做什麼矮人要塞之類的 ASCII 遊戲…這種遊戲大概不太有人想玩了。
Amethyst 使用了一套叫 specs 的 ECS Entity-Component-System 框架,當然,也是 Rust 寫的,ECS 概念是:所有的遊戲裡面的物件都是一個 entity(實體),上面可以附上很多的 component(部件,或零件),System(系統)則會去操作這些 component,entity 本身只是帶著 component 走的容器,我們後面實作系統就會更明白這點。

我們先來個簡單的重構,創一個新的檔案:states.rs 並把在 main.rs 裡面的 state 搬出來:
use amethyst::{
  assets::{AssetStorage, Loader, Handle},
  core::transform::{Transform},
  prelude::*,
  renderer::{Camera, ImageFormat, SpriteSheet, Texture, SpriteSheetFormat, SpriteRender},
};

pub const ARENA_HEIGHT: f32 = 300.0;
pub const ARENA_WIDTH: f32 = 300.0;

pub struct AsteroidGame;

impl SimpleState for AsteroidGame {
  fn on_start(&mut self, data: StateData<'_, GameData<'_, '_>>) {
    let world = data.world;
  }
}
這次我們引入更多的 module,載入資源的 assets、控制位置轉換的 transform、Camera、顯示 Sprite 用的元件;下面定義我們場地的大小,最後是我們已經熟悉的 State Struct。
從 StateData 裡面可以拿到遊戲的 world,world 裡面會存有所有遊戲的資料:resource、entity 和 component。

第一步先來產生我們第一個 entity:一台相機。
fn initialize_camera(world: &mut World) {
  let mut transform = Transform::default();
  transform.set_translation_xyz(ARENA_WIDTH * 0.5, ARENA_HEIGHT * 0.5, 1.0);

  world
  .create_entity()
  .with(transform)
  .with(Camera::standard_2d(ARENA_WIDTH, ARENA_HEIGHT))
  .build();
}
我們產生一個位移定在遊戲場景中間,Z 為 1.0 的相機,之後產生的物件 Z 軸會定在 0.0 上面,Amethyst 的座標是第一象限往右往上愈大,以這個相機的可視範圍來說,左下是 (0.0, 0.0) 右上是 (WIDTH, HEIGHT);產生 entity 只需要呼叫 world.create_entity ,並把需要的 component 用 with 塞進去就可以了。

第二步來產生寫第一個 component,開一個新的檔案 components.rs 並填入下面的內容:
use amethyst::{
  ecs::prelude::{Component, DenseVecStorage},
};

pub struct Ship {
  pub acceleration: f32,
  pub rotate: f32,
  pub reload_timer: f32,
  pub time_to_reload: f32,
}

impl Ship {
  pub fn new() -> Self {
    Self {
      acceleration: 80f32,
      rotate: 180f32,
      reload_timer: 0.0f32,
      time_to_reload: 0.5f32,
    }
  }
}

impl Component for Ship {
  type Storage = DenseVecStorage<Self>;
}
component 沒有什麼特別的,就是單純的一個 struct,在我們實作了 Component 之後,就可以把這個 struct 塞進 entity 裡面,Component 裡面可以什麼都沒有,單純做個標記;也可以像現在這樣,存有一個 Ship 所需要的性質。
實作 Component 的時候,都會需要指定不同的儲存方式,specs 裡面有五種不同的儲存方式可選,針對存取速度、記憶體用量有不同的最佳化,我們還是小遊戲的時候,基本上無腦的用 DenseVecStorage 就行了。

第三步我們要載入 Sprite。
回到我們的 states.rs,加上載入 sprite_sheet 用的函式庫
fn load_sprite_sheet(world: &World) -> Handle<SpriteSheet> {
  let texture_handle = {
    let loader = world.read_resource::<Loader>();
    let texture_storage = world.read_resource::<AssetStorage<Texture>>();
    loader.load(
      "texture/ship.png",
      ImageFormat::default(),
      (),
      &texture_storage,
    )
  };

  let loader = world.read_resource::<Loader>();
  let sprite_sheet_store = world.read_resource::<AssetStorage<SpriteSheet>>();
  loader.load(
    "texture/ship.ron", // Here we load the associated ron file
    SpriteSheetFormat(texture_handle),
    (),
    &sprite_sheet_store,
  )
}
我們把所有圖形資源都放在 assets/texture 裡面,一套資源是一個 png 檔配上一個 ron 檔,ron 檔描述一系列資源的起始座標跟大小,除了 png 檔 amethyst 也能讀入其他的檔案如 3D 模型等;以這邊的 ship.ron 為例,指定圖片大小為 16x16,內含一個 sprite 從 (0,0) 到 (16,16):
List((
  texture_width: 16,
  texture_height: 16,
  sprites: [
    (
    x: 0,
    y: 0,
    width: 16,
    height: 16,
    ),
  ],
))
先用 amethyst 內提供的 loader 把整個 png 檔讀進來,變成 world 內部的 texture resource(資源),resource 和 component 類似但不會綁定在某個 entity 上面,load 函式會回傳一個 Handle<Texture> 指向 AssetStorage<Texture> 裡 png 檔被讀進的位置,Handle 的實作類似 reference count pointer,讓所有人都可以共用一個 asset。
png 檔被讀入 AssetStorage 後,再次使用 loader 把 texture 讀入變成 SpriteSheet,這次回傳的內容會是 Handle<SpriteSheet> 指向 AssetStorage<SpriteSheet> 中的位置。

最後一步,我們把上面一切都整合起來:
fn initialize_ship(world: &mut World, sprite_handle: Handle<SpriteSheet>) {
  let mut transform = Transform::default();
  transform.set_translation_xyz(ARENA_WIDTH * 0.5, ARENA_HEIGHT * 0.5, 0.0);

  let sprite_render = SpriteRender {
    sprite_sheet: sprite_handle,
    sprite_number: 0
  };

  world
    .create_entity()
    .with(transform)
    .with(sprite_render.clone())
    .with(Ship::new())
    .build();
}
initialize_ship 跟 initialize_camera 沒有差太多,參數的 sprite_handle 來自剛剛的函式 load_sprite_sheet;位移設定太空船的位置在畫面正中間,從 sprite_sheet 產生 sprite_render,SpriteRender 就是真的顯示在畫面上的物件了,如果一組 sprite 裡面有數個 sprite,可以用 sprite_number 去指定要顯示哪個;最後生成 entity 並把 transform、sprite、Ship 三個 component 加上去即可。
有關 Sprite 的部分,因為有點複雜,我簡單整理起來是這個樣子:
  1. 呼叫 Loader 將圖片載入為 Texture -> Handle<Texture>
  2. 呼叫 Loader 讀 ron 檔,將圖片分割為 SpriteSheet -> Handle<SpriteSheet>
  3. 從 Handle<SpriteSheet> 生成 SpriteRender -> 作為 Component 放到 entity 中
impl SimpleState for Asteroid {
  fn on_start(&mut self, data: StateData<'_, GameData<'_, '_>>) {
    let world = data.world;

    let sprite_sheet = load_sprite_sheet(world);

    world.register::<Ship>();

    initialize_camera(world);
    initialize_ship(world, sprite_sheet);
  }
}
在 state on_start 時,呼叫 load_sprite_sheet 得到 Handle<SpriteSheet>,呼叫 initialize_camera 和 initialize_ship 初始化 camera 跟 ship entity。
有一行特別是這行 world.register::<Ship>()
如果不加這行的話,會遇到執行期錯誤:
thread 'main' panicked at 'Tried to fetch resource of type `MaskedStorage<Ship>`[^1] from the `World`, but the resource does not exist.

You may ensure the resource exists through one of the following methods:

* Inserting it when the world is created: `world.insert(..)`.
* If the resource implements `Default`, include it in a system's `SystemData`, and ensure the system is registered in the dispatcher.
* If the resource does not implement `Default`, insert it in the world during `System::setup`.

[^1]: Full type name: `specs::storage::MaskedStorage<rocket::entities::Ship>`', /home/yodalee/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/src/libstd/macros.rs:16:9

這個原因是 component 內的 storage 要經過初始化才能使用,我們在 entity 裡面使用了 Ship 這個 component 卻沒初始化 storage,程式就爆掉了,register 就是向 world 註冊並初始化 component;未來我們加上 System 之後,只要有被 System 使用的 Component 都會自動初始化,這行就不需要了。
經過這麼一團千辛萬苦,現在執行起來就能看到飛船在中間的畫面了。


使用 Amethyst Engine 實作小行星遊戲 - 1 設定專案

首先我們要先設定專案,內容會對應官方教學的 Getting Started 和 Pong 的第一章

設定專案上我個人建議是使用 amethyst 自己的工具,可以用 Cargo 安裝比較簡單;不然就要 clone 官方提供的啟動專案,也不是不行但就不能帶著走了;再其次當然就是用 Cargo new 生一個空專案,在 Cargo.toml 裡面加上 amethyst 的 dependency,這個真的很麻煩完全不建議:
cargo install amethyst_tools
amethyst new <game-name>

專案會自動新增 Cargo.toml,比較重要的是下面的 features,amethyst 可以選擇依賴的底層 API,如果是 Linux/Windows 選 Vulkan;蘋果用戶選 Metal;empty 我編譯完成圖形介面會出不來所以就不要用了。
[package]
name = "rocket"
version = "0.1.0"
authors = []
edition = "2018"
[dependencies]
amethyst = "0.15.0"
[features]
default = ["vulkan"]
empty = ["amethyst/empty"]
metal = ["amethyst/metal"]
vulkan = ["amethyst/vulkan"]
專案建好可以直接先開始編譯,amethyst 框架滿大的,第一次編譯會花上幾分鐘的時間把整個框架給架起來,不過放心,編好一次之後編譯都只要編譯你寫的 code,速度會快很多(雖然說我覺得還是很慢,大概 30 秒至一分鐘不等,Rust 真的很適合這張圖)。

預設版本執行起來應該會看到完全空白的畫面,生成的 main.rs 如下所示:
use amethyst::{
  core::transform::TransformBundle,
  prelude::*,
  renderer::{
    plugins::{RenderFlat2D, RenderToWindow},
    types::DefaultBackend,
    RenderingBundle,
  },
  utils::application_root_dir,
};
一開始當然是引入需要的模組。
struct MyState;
impl SimpleState for MyState {
  fn on_start(&mut self, _data: StateData<'_, GameData<'_, '_>>) {}
}
定義遊戲的 struct,amethyst 把遊戲分為不同的狀態,例如遊戲開始的時候會載入資源,這時候要顯示載入中;載入完要顯示選單,在 amethyst 裡面這些都是不同的狀態,遊戲本質上來說就是在這些狀態間切來切去。Amethyst 使用一個 stack 來管理 state,最上層就是目前執行的 state。
不過呢,因為現在要做的範例還沒有這麼複雜,state 的部分我們就先跳過,我自己也還沒學會(欸,這應該要等到最後我們開始把遊戲外面包上介面的時候再來學就好了。
這邊使用 amethyst 提供的 SimpleState trait,它已經幫我們實作了事件的介面,比自己實作完整 state 簡單。
fn main() -> amethyst::Result<()> {
  amethyst::start_logger(Default::default());
  let app_root = application_root_dir()?;
  let assets_dir = app_root.join("assets");
  let config_dir = app_root.join("config");
  let display_config_path = config_dir.join("display.ron");
進到主程式第一件事就是先打開 logger(然後我找不到怎麼記下 log 的文件…),用來記錄事件/警告/錯誤等等。
設定 assets_dir 跟 config_dir 並讀入 display.ron 設定檔,ron 是款 rust 專門的格式, config/display.ron 會記錄視窗的標題,還有它的畫面尺寸等資訊:
(
  title: "rocket",
  dimensions: Some((1000, 1000)),
)
所有可以設定的選項可以參考 DisplayConfig 的文件
  let game_data = GameDataBuilder::default()
    .with_bundle(
      RenderingBundle::<DefaultBackend>::new()
        .with_plugin(
          RenderToWindow::from_config_path(display_config_path)?
        .with_clear([0.34, 0.36, 0.52, 1.0]),
      )
    .with_plugin(RenderFlat2D::default()),
    )?
    .with_bundle(TransformBundle::new())?;

  let mut game = Application::new(assets_dir, MyState, game_data)?;
  game.run();
  Ok(())
}
剩下的 main 內容就是把 game_data 建起來,預設我們引入兩個 bundle,用來顯示的 RenderingBundle 跟做圖形轉換用的 TransformBundle。
首先我們生一個 RenderingBundle,裡面有兩個 plugin,RenderToWindow 讀入我們寫好的 display.ron 並顯示主視窗;RenderFlat2D 可以在畫面上顯示 Sprite。
最後拿著我們生成的遊戲狀態 MyState、 game_data 全部塞進 Application 裡面就可以了;看到這麼一大團程式碼,就可以理解為什麼一定要用 amethyst 工具幫我們生出預設的設定了,自己手爆這堆東西一定會累死。

到這邊我們就寫好一個空白視窗的小程式了,下一步我們要在畫面上畫上一些東西。


2020年6月24日 星期三

使用 Amethyst Engine 實作小行星遊戲 - 目錄

故事是這個樣子的,之前因為武肺的關係耍廢了一陣子,你看 blog 都沒更新幾個月了,只有中間在那邊玩 vscode 整個就是魯廢。
最近受到強者我同學在歐陸大殺四方的呂行大神感召,試玩了一下 Rust 的 amethyst (紫水晶,託名字的福一定要查 rust amethyst 才會查到要的東西)框架,決定來寫點文介紹一下。

當初看到 amethyst 是在 rust 的 are we game yet 頁面上看到的,如果你只是要寫簡單遊戲的話,rust 有另一套也算知名的引擎 Piston,用量目前比 amethyst 還要高一截,但我覺得 piston 的潛力不及 amethyst,雖然完整但 piston 在虛擬化上面沒有 amethyst 這麼高階,導致很多東西還是要設計師自己跳下去設計,相對來說就是學習曲線比較淺,有經驗的話看看文件就能上手。
不過,現下一般來說找不太到用 piston 或 amethyst 寫的大型遊戲,在範例頁面兩者做的都只是些老遊戲;不過話說回來做遊戲本來跟遊戲引擎就沒什麼關係,比較像是你整體企劃跟資源有沒有弄好,沒引擎還是可以寫個爆紅的 2048 或 flappy bird,有了好引擎還是會搞出歷史性的糞作,像是最後生(消音。

總而言之 amethyst 是(另)一套遊戲框架,背後的設計邏輯是所謂的 ECS:entity、component、system,是有人說 ECS 在 gaming 有 buzzword 的意味,但畢竟兩個比較大的遊戲引擎:unity 跟 unreal 都用上了 ECS 的概念,我想這部分應該是沒什麼疑慮。
在這個系列文,我預計會用 amethyst 寫一個打小行星的經典小遊戲,先聲明這的專案是從這個同樣的專案複製而來,它也是用 amethyst 寫的,只是年代久遠現在已經編不起來了,我直接拿了它的素材來用(應該是不至於被吉吧Orz),完成的畫面應該會如下所示:


如果去看 amethyst 的教學文,它有用 amethyst 寫一個 pong 遊戲,但我覺得 pong 不算一個好的例子,它不會生成跟刪除新的物體,偏偏這應該是很多遊戲必備的功能,用打小行星這種比較能示範怎麼做。
總之讓我們開始吧,這篇就作一個目錄的角色,用來連接所有教學文,希望能對大家成功傳教(欸。

2020年5月16日 星期六

第一次跳槽 vscode 就上手

故事是這樣子的,小弟第一次學寫 code 的時候,是在大一修計算機程式(嚴格來說是高三下學期上了幾個小時的 C,不過那實在稱不上是"學")的時候,第一個使用編輯器是破舊破舊的 Dev C++ ,我打這篇的時候差點都忘了它叫 Dev C++ 了。
當然那時候的功力跟現在實在是天差地遠,淨寫一些垃圾,啊雖然現在也是淨寫一堆垃圾…。
總之後來應該是大二,被同學們拉去演算法課上當砲灰,第一次接觸了工作站 + vim,從那時候把 Dev C++ 給丟了跳槽到 vim,就一直用到現在,之中當然也會用一下其他的編輯器,像是改 windows 的 .NET 程式用到 Visual Studio,但大體還是以 vim 為主力,算算也是超過 10 年的 vimer 了。

不過這兩三年在工作上、日常 project 上面,多多少少都見識到 vim 的不足之處,例如新語言(主要是 Rust)支援不足、跟編譯除錯工具整合不佳、跟 GUI 整合不佳、跟 Git 整合不佳要另外開終端機跟 gitg、自動格式化/排版操作麻煩而且通常排不好;正好此時 Microsoft 回心轉意擁抱開源,推出了 vscode,隔壁棚的 emacs 有大神跳槽鬧得風風雨雨,台灣 CUDA 第一把交椅強者我同學 JJL 也跳槽 vscode 惹還來傳教。

正好最近寫 code 沒什麼靈感,而且最近正好武漢肺炎的關係時機歹歹,就來試著跳槽一下吧(?,到目前為止用 vscode 對最近碰的一個 ncollide package 做了一些除錯的工作,筆記一下到目前為止的設定還有使用方式的筆記。

vscode 基本上的優勢就是是它編輯/建構/除錯三位一體的編輯介面;還有它的擴充功能,用過的都說讚。
擴充方面主要參考的文件有兩個:VSCode 如何提高我的寫扣效率小克的 Visual Studio Code 必裝擴充套件,另外台灣 CUDA 第一把交椅強者我同學 JJL 大大也有推薦一些:

擴充的安裝方式是按快捷鍵 Ctrl + P,打入 ext install 後面接套件名,下面擴充的連結裡面也有顯示安裝的指令:

vim 擴充,讓 vscode 的編輯介面套用 vim 的操作方式,想要手跟鍵盤黏踢踢就一定要裝
語言相關:
C/C++ 擴充:還沒試用只是覺得起家的 C++ 必須裝一下:
Python 擴充:一樣還沒試用只是覺得有一天會寫到先裝一下:
Rust 擴充:這個是這次語言唯一試用過的,雖然結果不怎麼樣
codelldb 除錯擴充,可以用 LLVM 的 lldb 對程式除錯,裝了這個是為了要對 Rust 除錯

工具類:
Git Graph:整合 gitg 類似的圖形化顯示工具到介面,git 管理上當然可以靠打字,但看歷史還是看圖方便
GitLens:還沒試過,強者我同學 JJL 推薦的
TODO tree:統一管理 project 內部的 TODO, FIXME, XXX
Trailing Spaces:自動刪掉程式碼行尾的空白
Markdown Github Style:編輯 markdown 文件時可以直接預覽輸出的格式,解決每次編輯 Github README.md 都要一直 push -f 直到格式完全改對為止,這點很強烈的突顯出 vim 等純文字編輯器的弱項,無法和圖形整合,以致在 markdown、LaTex 這類文字和顯示有相互關係的文件編輯會很吃虧(好啦好啦我知道有人能人腦 render latex 的)。

怎麼建構專案?
在 vscode 裡面的建構叫 task,在選單 terminal 下面的 run Task 跟 run Build Task (Ctrl + Shift + B),沒有 cargo 預設的話就要自行編輯 tasks.json,以下是我這次 debug 時使用的 tasks.json
{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "cargo run",
      "type": "shell",
      "command": "cargo",
      "args": ["build"],
      "group": {
        "kind": "build",
        "isDefault": true
      }
    }
  ]
}
應該滿直覺的,就是呼叫 cargo build 幫我編譯整個專案;在寫完 code 之後使用快捷鍵 Ctrl + Shift + B 就能編譯專案了。

如何除錯:
除錯是 vscode 一項殺手級的功能,vscode 公開一個 API 讓安裝的語言擴充使用,需要什麼語言的除錯安裝擴充就好,像我上面就安裝了 C/C++, Python, Rust 的擴充。如果我記憶沒錯的話,跟 visual studio 一樣,vscode 快捷鍵是也執行 Ctrl + F5 跟除錯 F5:

用 Ctrl + Shift + D 展開 debug 介面。
理論上用滑鼠在原始碼旁邊點一下就能加上 breakpoint 不知道是 Rust 還是 lldb 的問題,我用滑鼠加上去的 breakpoint 都煞不住,至少一定要先在 debug console 裡下一個 b main 讓程式煞住之後,用滑鼠加的 breakpoint 才會有用,真的很奇怪。
另外就是 debug console 的指令跟習慣的 gdb 有點不同要重新習慣,最奇怪的大概是按了 enter 竟然不會重複上個指令,這樣要一直按 n + enter + n + enter 怪麻煩的,只能去習慣 vscode 的除錯指令:F10/next、F11/step、Shift+F11/finish 了。

這次最主要的目的是要對 Rust 程式除錯,我參考的是下面這篇文章…不過試用之後沒有成功
首先我們要加上一個 launch.json 告訴 vscode 要怎麼跑除錯的程式:
{
  "version": "0.2.0",
  "configurations": [
  {
    "name": "Debug example contact_query2d",
    "type": "lldb",
    "request": "launch",
    "program": "${workspaceRoot}/target/debug/examples/contact_query2d",
    "args": [],
    "cwd": "${workspaceRoot}",
  }]
}
再來用 F5 就能開始除錯了,但不知道為什麼我 step into 一個函式,瞬間都變成 assembly code,連 stack 資訊都爛掉了,根本無從 debug 起,感覺是 vscode 哪裡跟 lldb 沒弄好,不過我覺得這不是 vscode 的問題,畢竟我在終端機用 rust-gdb 一樣會有問題,正好反過來如果下 b main 停下來的話,rust-gdb 下一步會停不下來,一口氣跑到 main 的尾巴…。
這個問題一時之間好像無解,也許要等 rust 跟 codelldb/gdb 真的接好之後再來看看了。

下面就是個零碎操作:
Ctrl + KT 叫出 color theme 設定,我用的是 light 的 Solarized Light ,最近眼睛好像不太適合全黑的畫面了QQ。

回頭一看怎麼一堆快捷鍵,不過算啦,跟 vim 的快捷鍵比起來這還是算少的吧XD;話說大概是「把手留在核心區」這個哲學的關係,vim 大部分的按鍵都少有用 Ctrl/Alt 開頭的,剛好一般的圖形應用程式包括 vscode ,大部分的快捷鍵都是 Ctrl/Alt 開頭,也因此在操作上面,vim 很容易就能跟桌面應用程式整在一起,就像是瀏覽器的 vimium 跟 vscode vim 擴充。

2020年3月1日 星期日

Rust std process 生成子行程

最近在玩 Rust 的時候,需要用 Rust 去呼叫一些 shell command 來幫我完成一些事,幸好 Rust std 裡面已經有 process 來幫我們完成這件事,使用起來很像 python 的 subprocess,不過實際在用遇到一些問題,所以寫個筆記記錄一下:

首先當然是從 std 引入這個模組的 Command,Stdio 很常用也順便 include 一下:
use std::process::{Command, Stdio};

一切的基礎就是一行 Command::new(command_name),在 command_name 的地方填入你想呼叫的指令。
Command 代表了一個準備好要跑的命令,就像是在 shell 裡面打下 command_name 直接按 enter 一樣,沒有參數、繼續現在行程的環境、位置和現在行程的位置相同。
如果要設定給命令的參數,就用 .arg 塞進去,如下面的例子:
let mut ls = Command::new(ls).arg("-al");
這個參數一次只能塞一個,有多個參數要連續呼叫 .arg 才行。

有個 Command 之後接下來有三種方式讓它跑起來:.spawn(), .output(), .status():

  • spawn fork 子行程執行,拿到一個子行程的 handler,回傳的型別是 Result<Child>。
  • output fork 子行程執行,等待(wait)它結束之後,收集它寫到 std output 的內容,回傳的型別是 Result<Output>。
  • status fork 子行程執行,等待它結束之後,收集它回傳的資訊,回傳的型別是 Result<ExitStatus>。

第一個可以注意到的是回傳的型別都是 Result,這是因為 command 可能會跑起來也可能會跑不起來,像是我打一個 Command::new("www") 但我的 shell 根本沒 www 這個指令,Result 提醒了這個可能性的存在,一般來說這邊最簡單的就是用 .expect 把 Result 解開。
第二個另人疑惑的,是後面的 Child, Output, ExitStatus 是什麼鬼,整理之下大概是這樣:

  • ExitStatus 是最簡單的,就是行程結束的狀態的封裝,Rust 提供兩個介面 success 跟 code 來判斷子行程有沒有正常結束以及對應的 exit code。
  • Output 是更上一層,裡面包了一層 status : ExitStatus,加上兩個 stdout, stderr 的 Vec<u8>,裡面存了子行程所有寫到 stdout 跟 stderr 的內容。
  • 最外層就是由 spawn 產生的 Child,比起 output 跟 status 一生成行程就自動幫你 wait,spawn 給了完全的操作能力,可以做更多事情。

三個啟動的函式影響最大的就是子行程的 stdin/stdout/stderr,在 spawn 跟 status 下,stdin/stdout/stderr 會繼承父行程的 stdin/stdout/stderr;在 output 時,stdin 會被設定成不可使用(接到 /dev/null), stdout 跟 stderr 則會設定成 piped 來讀取。
如果不想用預設的設定,可以在呼叫 status/output/spawn 前做設定,有三個選項可選 Stdio::inherit、Stdio::piped、Stdio::null,分別就是繼承父行程、接 piped 到父行程跟接上 /dev/null 。

現在就能來玩一些例子,例如在 rust 裡面呼叫 ls,用 status() 的話輸出會直接輸出到螢幕上面:
let p = Command::new("ls")
    .arg("-al")
    .status()
    .expect("ls command failed to start");
drwxr-xr-x 5 yodalee yodalee 4096 2月 29 09:45 .
drwxr-xr-x 16 yodalee yodalee 4096 2月 28 20:10 ..
-rw-r--r-- 1 yodalee yodalee 62279 2月 29 00:07 Cargo.lock
-rw-r--r-- 1 yodalee yodalee 312 2月 29 00:07 Cargo.toml

如果想要把 ls 的內容截下來的話,就要改用 output:
let p = Command::new("ls")
    .arg("-al")
    .output()
    .expect("ls command failed to start");
這時候可以從 p.stdout 裡面拿到 Vec<u8>,要轉成字串就要用 String 的 from_utf8/from_utf8_lossy/from_utf8_unchecked 函式轉。
let s = from_utf8_lossy(&p.stdout);
println!("{}", s);
drwxr-xr-x 5 yodalee yodalee 4096 2月 29 09:45 .
drwxr-xr-x 16 yodalee yodalee 4096 2月 28 20:10 ..
-rw-r--r-- 1 yodalee yodalee 62279 2月 29 00:07 Cargo.lock
-rw-r--r-- 1 yodalee yodalee 312 2月 29 00:07 Cargo.toml

如果要對子行程上下其手有完全的操控,就要使用 spawn 了,不過相對來說也要小心,因為 spawn 不會自動幫你 wait,不小心就會把子行程變殭屍行程。
產生出來的 Child 物件,本身就自帶一些函式,像

  • kill() 發 SIGKILL 把子行程砍了。
  • wait()、wait_output() 等待子行程結束,spawn + wait/wait_with_output 就相當於直接呼叫 status/output。

我們用 shell 的 rev 當作例子,它會輸入 stdin 反轉之後輸出,這裡不能用 output() 因為 output 的 stdin 不會打開;可以用 status ,這樣 stdin 會繼承本來的 shell 的 stdin 讓我們打字,但如果我們是要反轉程式裡面的一行字串呢?這時候我們就要用 s.chars().rev().collect::<String>() 然後這篇文就不用寫了 spawn 再操作 stdin 了。

具體來說大概像是這樣:
let mut p = Command::new("rev")
    .stdin(Stdio::piped())
    .spawn()
    .expect("rev command failed to start");
let stdin = p.stdin.as_mut().expect("Failed to open stdin");
stdin.write_all("Hello".as_bytes()).expect("Failed to write stdin");

本來用 spawn 的話子行程的 io 會繼承父行程的,相當於上面那行改成 .stdin(Stdio::inherit()),這裡我們改用 Stdio::piped() 把它接出來。
接著我們可以從 p (型別是 process::Child)裡去取得它的 stdin, stdout, stderr,這個拿到的都是 Option 型別,用 expect 把它給解開來,裡面就會拿到 Rust 的 io 物件,可以用呼叫對應write 系列函式對它寫入內容,這裡用 write_all 對 stdin 寫入 "Hello" 的 Vec<u8>。
在 stdout 螢幕上就會看到 "olleH" 的輸出了。

當然我們也可以在呼叫的時候把 stdout 也導向 piped 處理,讓我們讀出反轉的結果:

let mut p = Command::new("rev")
    .stdin(Stdio::piped())
    .stdout(Stdio::piped())
    .spawn()
    .expect("rev command failed to start");

let stdin = p.stdin.as_mut().expect("Failed to open stdin");
stdin.write_all("Hello".as_bytes()).expect("Failed to write stdin");
let output = p.wait_with_output().expect("Failed to read stdout");
let revs = String::from_utf8_lossy(&output.stdout);
assert_eq!(revs, "olleH");

以上大概就是 Rust std process 使用方法的整理了,我自己大概有三點感想:
  1. 用 Rust 寫其實沒有比 C 用 fork/exec 來寫來得簡單多少,畢竟我們就是要操作子行程,底層都是系統程式那套,Rust 頂多是封裝得比較完善一點,實際上用起來該設定的一個少不了。
  2. 要寫系統程式,系統程式的概念少不了,要寫 process 至少需要知道作業系統行程的概念(不然一不小心會變成 World War Z 殭屍產生器),操作輸入輸出需要大略知道 file descriptor 的概念,不然文件的繼承 stdin/stdout/stderr,piped 根本看不懂,不管你用哪套語言哪個作業系統,這些基本知識是逃不掉的。
  3.  雖然如此,我覺得 Rust 仍然提供了一套不錯的封裝,在函式的回傳值上套用 Result/Option 的方式,能有效提醒使用者可能發生的錯誤,並要求使用者必須處理他們,這點我認為是花了差不多的成本之後,Rust 唯一可以勝過 C 的地方。
不小心寫了落落長,如果你竟然看到這行了,希望這篇文章對你有幫助XD。

2019年11月16日 星期六

把一顆樹寫出來是會有多難

故事是這樣子的,之前小弟發下豪語想用 Rust PEG 寫一個 C Parser,然後…就沒有然後了。好啦當然不是,不然就不會有這篇文了。
總之最近經過一陣猛烈的攪動之後,我的 parser 能處理的文法終於接近當年在學校修 compiler 的時候所要求的 B language 了,說來慚愧,當年寫 compiler 作業的時候 parser 只是裡面一個作業,要在 2-3 週裡面寫完的,結果現在搞半天寫不出個毛,果然上班跟上學還是不一樣,在學校可以全心全意投入寫 code ,週末的時候還可以熬個夜把作業寫出來;現在上班白天要改公司的 code ,晚上回家累個半死不想寫 code 只想開卡車(欸。

本篇講到的程式碼目前還沒推到遠端上,相關的程式碼可以參考:
AST 的資料結構:cast
型別的資料結構:ctype
既然現在可以處理比較複雜的文法了,再來要做什麼?想說就像作業的要求一樣,把我們處理好的 AST 用 graphviz 寫出去,是會有多難?

整個 dump graphviz 的進入點是一個函式,接收要倒出來的 AST 跟一個 out,out 的型別是 std::io::Write 的 dyn Write,這樣不管你是要寫到 stdout, stderr 還是寫到檔案都能傳進來,介面會是一樣的,函式的實作當然就是直接了當的把該印的東西都寫出去;另外實作一個 dump_node 幫我們把寫出一個 node 給獨立出來,id 會自動不斷累加,讓 node 的編號不會重複。
fn dump_graphviz(ast: CastTop, out: &mut dyn Write) {
    writeln!(out, "Digraph AST").unwrap();
    writeln!(out, "{{").unwrap();
    writeln!(out, "label = \"AST_Graph.gv\"").unwrap();
    writeln!(out, "node{} [label = \"PROGRAM_NODE\"]", 0).unwrap();
    ast.make_node(out, &mut 0);
    writeln!(out, "}}").unwrap();
}
fn dump_node(out: &mut dyn Write, id: &mut u32, label: &str) {
    *id += 1;
    writeln!(out, "node{} [label = \"{}\"]", id, label).unwrap();
}
另外我們要實作的是 make_node,這裡很自然的就是先宣告一個 trait,AST 裡面所有的物件都要實作這個 trait ,就都有 make_node 可以用了。
trait ToGraphviz {
  fn make_node(&self, out: &mut dyn Write, id: &mut u32);
}

impl ToGraphviz for CastTop {
  fn make_node(&self, out: &mut dyn Write, id: &mut u32) {
    match self {
      CastTop::FuncDeclList(v) => {
        let cur_id = *id;
        for decl in v {
          dump_node(out, id, "DECLARATION_NODE FUNCTION_DECL");
          decl.make_node(out, id);
        }
        if *id != cur_id { // new node
          writeln!(out, "node{} -> node{} [style = bold]", cur_id, cur_id + 1).unwrap();
        }
      },
    }
  }
}

impl ToGraphviz for FuncDecl {
  fn make_node(&self, out: &mut dyn Write, id: &mut u32) {
    let parent = *id;
    dump_node(out, id, &format!("IDENTIFIER_NODE {} NORMAL_ID", "int"));
    dump_node(out, id, &format!("IDENTIFIER_NODE {} NORMAL_ID", self.fun_name));
    dump_node(out, id, "PARAM_LIST_NODE");
    dump_node(out, id, "BLOCK_NODE");

    for i in parent..*id {
      writeln!(out, "node{} -> node{} [style = {}]",
          i, i+1, if i == parent {"bold"} else {"dashed"}).unwrap();
    }
  }
}
* :本來的作業要求連結第一個 child 的必須是實線,其他的用虛線,這裡沿用
這個實作的問題顯而易見,我們的輸出的實作跟資料綁死了,所以每個 node 裡面的實作都是大費周章,而且 code 很醜。
我們要更抽象化一點,其實輸出樹的邏輯是這樣子的:先寫 child 的 node,然後是自己,回傳自己的 id 給 parent,這樣上一層的人才能畫 edge 出來。
我們實作一個 dump_children 的函式,這個函式會用現在的 id 印出現在的 parent,然後把它跟所有傳進來的 children 畫線連起來:
fn dump_children(out: &mut dyn Write, id: &mut u32, label: &str, children: &[u32]) -> u32 {
  writeln!(out, "node{} [label = \"{}\"]", id, label).unwrap();
  let mut prev = *id;
  for child in children {
    writeln!(out, "node{} -> node{} [style = {}]", prev, child,
        if prev == *id { "bold" } else { "dashed" }).unwrap();
    prev = *child;
  }
  *id+=1;
  *id-1
}
因為 Rust 函式參數沒有預設值也沒有 overload,為了方便我們可以創一個 dump_nochild 的函式,這樣比較方便:
fn dump_nochild(out: &mut dyn Write, id: &mut u32, label: &str) -> u32 {
  dump_children(out, id, label, &[])
}
現在 make_node 的實作都可以用 dump_children 或 dump_nochild 實作,先對自己的 child 們呼叫 make_node,把回傳值(也就是 child 們印完的 root)收集起來再用 dump_children 印出去就行了:
impl ToGraphviz for CastTop {
  fn make_node(&self, out: &mut dyn Write, id: &mut u32) -> u32 {
    match self {
      CastTop::FuncDeclList(v) => {
        let children : Vec<_> = v.iter().map(|n| n.make_node(out, id)).collect();
        dump_children(out, id, "PROGRAM_NODE", &children);
      },
    }
    *id
  }
}

impl ToGraphviz for FuncDecl {
  fn make_node(&self, out: &mut dyn Write, id: &mut u32) -> u32 {
    let children = [
      dump_nochild(out, id, "IDENTIFIER_NODE int NORMAL_ID"),
      dump_nochild(out, id, &format!("IDENTIFIER_NODE {} NORMAL_ID", self.fun_name)),
      dump_nochild(out, id, "PARAM_LIST_NODE"),
      dump_nochild(out, id, "BLOCK_NODE")];
    dump_children(out, id, "DECLARATION_NODE FUNCTION_DECL", &children)
  }
}
這樣看起來就好多了,不過我們還能更進一步,仔細觀察上面的 dump_children 的話,就會發現我們還能用 fold 的方式改寫:
// print node, and link with all children
fn dump_children(out: &mut dyn Write, id: &mut u32, label: &str, children: &[u32]) -> u32 {
  *id+=1;
  writeln!(out, "node{} [label = \"{}\"]", id, label).unwrap();
  children.iter().fold(*id, |mut prev, child| {
      writeln!(out, "node{} -> node{} [style = {}]", prev, child,
          if prev == *id { "bold" } else { "dashed" }).unwrap();
      prev = *child;
      prev});
  *id
}
老實說,每次我費了這麼大的工夫,把一堆本來很黃很暴力的 code 改簡單,變成最後那樣的很純很 Functional 的 code,我都會在內心懷疑個 100 遍,費這麼大功夫是真的有比較快嗎?當然在維護上可能會好一點,但 Rust compiler 能保證抽象化真的是零成本的嗎?這可能是值得好好討論的議題。

每個函式都要帶著 out 跟 id 走,很不方便,用一個 struct 把它們裝起來:
struct DumpGraphviz {
  out: Box<dyn Write>,
  id: u32
}
dump_children 跟 dump_nochild 變成 DumpGraphviz 的實作,介面變成:
fn dump_children(&mut self, label: &str, children: &[u32]) -> u32
fn dump_nochild(&mut self, label: &str) -> u32
make_node 的介面則是:
fn make_node(&self, visit: &mut DumpGraphviz) -> u32
整體就變得清爽多了。
天底下沒有新鮮事,其實我就是在實作 visitor pattern,只是還沒把 visitor 整個抽出來讓不同的 visitor 可以在這上面實作。最後輸出的成品長這個樣子:

我有個小小的體悟,就是寫程式不要妄想一步登天,除非如強者我同學 AZ 大大那樣一眼就把超大程式的架構都畫出來,而且實作起來都不會亂掉。
我上一次的實作就是衝太快,翻著 C standard 想要一開始就照著 C standard 實作,然後文法寫得亂七八糟反而連簡單的文法都會大噴射無法處理;與其如此,不如先支援基本的功能,等 parser 跟文法處理都完善之後再慢慢把其他功能加上去。
我覺得用蓋房子比喻的話,寫大程式要像西敏寺那樣的大教堂一樣,先從一個功能完整的小教堂開始,然後把小部分拆掉蓋個更大更豪華的(有看過一個動畫片在演示這個過程的,只不過沒有公開版);如果一次就想蓋個超大的教堂,最後可能弄成一團廢墟,連禮拜的功能都沒有。

2019年11月4日 星期一

從 Coscup 小談 Rust

這篇其實有點拖稿,畢竟 COSCUP 都是幾個月前的事了;這次在 COSCUP 投稿了 Rust 議程軌,覺得可以來說說對 Rust 的一點感想。Rust 從問世、正式發佈到現在也差不多要 7 年,感覺近年來有愈來愈紅的趨勢,一種社群上面看一看發現大家都用過 Rust 的感覺。

今年的 COSCUP 專門開了一個 Rust 議程軌,而且感覺議程的內容正在提升,不再是一堆語言介紹,有更多的是在介紹用 Rust 實作的資料庫、web assembly 、類神經網路的應用,可以預見 Rust 正在走出推廣階段,前往實際應用的領域。
不過我們還是要回來問,Rust 在哪裡會有<十倍生產力>?也就是在哪裡可以把東西做得比其他語言十倍好,像是要推人工智慧大家就會推 Python;要寫高效能的網路可能會用 golang,有哪個領域是非用 Rust 不可的嗎?現在有些風聲是區塊鏈的合約和交易語言,但我對這塊應用的大小有點存疑。

Rust 天生尷尬在它的定位上,它的目標是一個安全高效的系統程式語言,它也的確有潛力做到這點,但整體看來 Rust 可能是幾大系統程式語言裡數一數二複雜的,可能只輸給 C++,配上最新加上去的 Async 可能差不多就比肩了(欸。
確實 Rust 從源頭來看,受到大量函數式語言和語法的啟發,語法上看得出核心來自一個優異的語言團隊並吸收了各類語言的優點;編譯時進行的所有權確認和以 mod 為編譯單位,雖然讓 Rust 編譯慢得像烏龜,卻也大量消除程式在執行時出錯的機會,或者因為設計師<忘記>而導致的問題。
Rust 不可能是一款早期的語言,它浪費太多運算資源在編譯檢查,在 C 語言發跡的年代不會浪費資源去做那些檢查,換來的就是 Rust 編譯器數一數二的 GY 程度,這個不行那個也不行,搞得寫 code 的人跟編譯器都很累……。

我認為 Rust 要走的會是一條很艱難的道路,Rust 內建的複雜性天生就拒絕了一些簡單的應用,用 Rust 寫起來太過繁瑣了,動態語言能搞定的網路服務開發速度是第一,程式設計師上手的速度還有開發的速度來看,沒理由不用動態語言;而一些偏底層的應用,特別是對從 C/C++ 來的人來說,Rust 根本就不可理喻,明明我用 C 系列一下就可以搞定的,誰跟你在那邊 4 種 String 還有一堆 Option 要處理?一眼看穿的程式實在用不上 Rust,有人覺得 Rust 可以在嵌入式系統上挑戰 C,我看再過 100 年都不太可能。
Rust 的優勢,要來到所謂的大型系統程式才會出現,透過編譯器的強制,把一些難以檢測到的記憶體問題給挑出來,當然用 C++20 的一些特性可以做到一樣的效果,但沒有編譯的強制只靠設計師所受的教育,在大型系統下畢竟不是一個妥當的做法,畢竟設計師也是人,不可能不犯錯,或者偷懶或者忘記,一不小心就引入 C++ 的舊語法 -- 那些為了向後相容絕對不會移除的部分。

但問題就在於:大型系統幾不太可能整個重寫,更別提底層所依賴的都是經過千錘百鍊的 C/C++ 函式庫,像 Mozilla 那樣決定把瀏覽器核心整個抽換掉真的是神經勇敢,市面上的大公司哪幾家做過一樣的事?
可以預期 Rust 幾年之內,都會是用滲透的方式慢慢進到各大公司的系統當中,也許是一個新實作的子系統或是重寫某些小部分,用 FFI binding 的方式和既有的系統銜接,但要成為主流我看還要努力一段時間才行。

其實我是覺得語言比語言氣死人,不過 Rust 對 go 一直是一個大家很有興趣的話題(雖然說兩個根本是完全不同的東西),我個人滿推薦 LoWeiHang 翻譯的這篇文章

2019年9月4日 星期三

Rust 裡面那些 String 們

故事是這樣子的,最近把小弟自幹的編譯器加上 rust 的 llvm wrapper llvm-sys,經過一陣猛烈的攪動之後,自幹的編譯器終於可以 dump LLVM IR 了,雖然只會輸出一個空殼子…但有第一步總是好的。
不過小弟在綁定的時候遇到一個大問題,也就是 Rust 裡面的 String,到底怎麼會有這麼多種,因為寫的時候一直沒搞清楚,然後就會被編譯器噴上一臉的錯誤,覺得痛苦,於是決定來打篇整理文。
簡單來說,Rust 的 std 有四種 String,每個 String 都有動態記憶體模式跟沒有 size 資訊(不是 Sized)的靜態模式,他們是:
std::string::String <-> std::str
std::ffi:OsString <-> std::ffi::OsStr
std::path::PathBuf <-> std::path::Path
std::ffi::CString <-> std::ffi::CStr
還有一個比較少用,只能表示 ascii 128 字元組成的字串的 std::ascii::asciiExt,這裡就不介紹了。

一般的程式語言在數字型態通常都很固定,Rust 就很明確的分為 i8, i16, i32, i64 …,就偏偏字串是個大坑,因為從 ASCII 到 unicode,字串實在有太多分岐,儘管有 unicode 也不是到處適用。Rust 從設計上一開始就直接採用 utf-8 作為設計標準,原生的 String/str 就是 utf 8 字串。
可是呢,並不是所有作業系統都玩 utf8 這套,因此 Rust 有另一個使用 wtf8 的 OsString,wtf8 跟 utf8 的差異在於 wtf8 算是<格式比較差>的 utf8,會出現一些 utf8 不允許的位元組,偏偏規格沒有要求一定要完美格式,造成 windows 或 javascript 有時會出現這種格式不良的 wtf8 字串,因此 OsString ,跟專門用來表示路徑的 PathBuf 就是使用 wtf8。
有關 wtf8 請參考:https://simonsapin.github.io/wtf-8/

上面的字串都是在型態中記錄字串長度,結尾不會有 \0 字元,CString 則是最傳統的 null-terminated 字串,在呼叫 C 函式的時候,一定要用 CString 傳遞才行。
順帶一提,一般寫在 code 裡面的 let hello = "hello world" 的型態是 &'static str:生命週期為 static 的靜態字串。

知道了以上幾個區別之後,就來看看要怎麼使用它們:
String 最簡單,裡面一定要是 utf8,產生就是從 static str 產生,或者是 new 之後慢慢 push 進去:
let hello : String = String::from("hello");
let mut world : String = String::new();
world.push_str("world");
world.push('!');
OsString 是類似的,但只能從 String 轉過來(注意 String 的所有權會轉給 OsString),或者一樣 new 之後 push String 進去:
use std::ffi::{OsString, OsStr};
let oshello : OsString = OsString::from(hello);
let mut world : OsString = OsString::new();
world.push("world!");
PathBuf 其實就想成 OsString 就好,兩者也可以互相用 from 轉換:
use std::path::{PathBuf, Path};
let p1 : PathBuf = PathBuf::from(oshello)
let mut p2 = PathBuf::new();
p2.push("/dev");
上面說了,OsString 跟 PathBuf 用的是 wtf8,是 utf8 的超集,因此一般只能單向從 String 到 OsString,反向是不行的,呼叫 OsString::into_string() 得到的是 Result<String, OsString>,也就是有可能會轉失敗;或者就是用 into_lossy_string 把編碼不完整的地方變成 U+FFFD,utf8 的 replacement character。
PathBuf 則是沒有 into_string 可以用,只能先轉換成 OsString 再轉過去,我也不知道為什麼 core team 要這樣設計。

剩下的就是函式了,很有趣的是 String, OsString, PathBuf 都是動態容器,操作內容都要轉換到 str, OsStr, Path 上面去:
str 有操作字串用的 split_whitespace, starts_with 等等
OsStr 沒有任何特殊的函式XD。
Path 有很多對路徑的操作:is_absolute, parent, with_extension 等等,很多函式操作後都會得到 Path 或是 OsStr 讓你做接下來的操作。

CString 比較棘手一點,它要在 new 的時候代入 Vec<u8> (或者有實作 Into<Vec<u8>> 的型態)來建立 CString,new 會自動在後面加上 \0 ,因此這個 Vec 裡面不應該有 \0。
其實我覺得把 CString 想得 Vec<u8> 的另一種型態就好了,它本身也提供 into_bytes, as_bytes 等函式轉換成 Vec<u8> 的型態。
如果要從 String 跟 OsString 轉換過來的話,String 要用 as_bytes() 轉成 Vec<u8>,OsString 因為 unix 跟 windows 會有不同的 OsString 實作,不一定都能轉成 Vec<u8>,在 unix 要引入 std::os::unix::ffi::OsStrExt 就可以將 OsString 用 as_bytes() 轉成 Vec<u8>;Windows 則建議轉成 String 再轉成 bytes ,請參考這個網址

用上了 CString,最重要的就是要交給外部的 C 函式去用,要用 as_ptr() 取出字串部分的 pointer,得到的就是 * u8 了,有必要的話再加上 as *const i8 轉型一下。
例如我要呼叫這個函式:
LLVMPrintModuleToFile (LLVMModuleRef M, const char *Filename, char **ErrorMessage)
這個函式,我的檔案名稱是一個 OsString:
use std::ffi::{CString, CStr};
use std::os::unix::ffi::OsStrExt;
llvm::core::LLVMPrintModuleToFile(
           self.module,
            path.as_bytes().as_ptr() as *const i8,
            ptr::null_mut());
看了這麼多,簡單整理一下大概是這樣:

老實說每次只要在 Rust 裡面弄到 Path 都會弄到懷疑人生……

2019年8月19日 星期一

第一次在 COSCUP 當講者就上手

故事是這樣子的,在上周結束了兩天的 COSCUP 行程,總算達成人生成就:參加 COSCUP (欸。
這次是以講者的身分去的,畢竟搶票什麼的實在是太難了,就跟搶普悠瑪一樣難,當講者好像比較簡單(True Story)。

這次準備的題目其實都是準備許久的,一個是本次 COSCUP 有開 Rust 議程軌,就把之前寫 computationbook-rust 裡面當範例的 simple language ,配上研究一小段時間的 PEG parser 挑出來,攪一攪投出去。本來這是想要去年的 MOPCON 投的,但畢竟 MOPCON 是以網路為主體,跟這 programming language 還是格格不入被拒絕了。

下面是投影片:


blog 的話,可見實作麻雀雖小五臟俱全的程式語言剖析表達文法 PEG 簡介使用 rust pest 實作簡單的 PEG simple 剖析器使用 procedence climbing 正確處理運算子優先順序幾篇。

另外一個議題則是去年 8-10 月做的 Nixie Tube Clock,COSCUP 有非常適合的硬體議程軌,老實說 Rust 議程軌我覺得不一定會上,硬體議程軌我就真的滿確定會上,畢竟講硬體的本來就少,Nixie Tube Clock 也滿完整的,果然最後就上了一場。
投影片在此:


blog 筆記總計有十篇:
0. 前言
1. 材料取得
2. 自組高壓電路
3. 驅動電路
4. 控制電路
5. 電路板基礎
6. 電路板實作 layout
7. 焊接
8. 寫 code
9. 後記

個人小小的體悟是,先不要想 COSCUP,先想著把某件事情做好,時候到了投稿自然會上;就像會上一位大大說的,因為沒搶到票決定每周用 golang 寫一個 project,52 週之後就當講者了。
這次投上的題目,無論是 PEG + programming language,還是 Nixie Tube Clock,都是一年前甚至兩年前開始的嘗試,PEG 還搞了個失敗的 C parser,blog 寫了好幾篇的題目,做到這種程度才能換到 40 分鐘的上台時間;也許現在就該來想一下要做什麼新題目了。

----

第一次參加 COSCUP ,這次真的融合了超多議程軌人超級多,據說直接突破 2000 人,大拜拜的意味滿重的,像 Pycon 這樣同時段 3 場的都很常兩場一定要選的,COSCUP 同時開 14 場議程,從一開始聽議程就不是目的了。
實際下來比較像:三分聽議程,七分面基友。
細數一下我到底遇到多少在網路上見過面的大大:像是從荷蘭遠道而來的呂行大大、台灣軟體界照世明燈郭神大大、久未見面的 jserv 大大、好高興教授大大、TonyQ 大大、在會前酒會遇見上海大殺四方的 Richard Lin 大大、曾經在高雄氣爆的時候幫我提升 Google Map 權限的 pingooo 教授大大;認識了台灣 maker 社群、Python HsinChu User Group - PyHUG。
不過我覺得比較扯的還是呂行大大,走一走每個攤位都能遇到人,真的是神猛狂強溫爽發。

記得以前參加 PyCon,總會在那邊要求自己盡量的聽,連可能不知道在講什麼的、 lightning talk 都聽完之類的,這幾年終於改掉這樣的習慣,發現時間寶貴,聽一些跟自己太遠的東西其實是浪費時間,還不如放點時間出來跟大家聊聊天,真的沒想聽的就早早離開會場沒差;網路上常講:
小孩子才做選擇,成年人當然是我全都要。
但其實,成年人才知道自己要什麼、不要什麼、有能力要什麼、沒能力要什麼,我覺得是反過來的:
成年人才做選擇,小孩子才是我全都要。

我想最後還是要感謝一些人,像是強者我同學 JJL 大大幫小弟 review 投影片;強者我同學 wmin0 大大幫小弟生出一個 Nixie Tube 的講題,這個題目應該給大大講才是。
明年希望大家也都能成為 COSCUP 講者。

2019年1月6日 星期日

用 PEG 寫一個 C parser 續

自從去年十月把 nixie tube clock 完工之後,好像都在耍廢之類的,結果 11/12 月兩個月都沒有發文,其實這兩個月裡面,有的時間都在改之前寫的 C parser,其實整體完成度愈來愈高了,今天發個文來整理一下到底做了啥。
這次做了幾個改變,主要的修正就是加上 expression, declaration, statment 的處理,也學到不少東西,這裡一一列一下:

macro

這是要對應之前寫的 parse_fail,本來 parse_fail 的用意,就是在剖析出錯的時候,把程式終結掉,然後丟一點錯誤訊息出來;本來我的實作是一個函式,利用 unreachable! 丟出錯誤:
fn parse_fail(pair: Pair<Rule>) -> ! {
  let rule = pair.as_rule();
  let s = pair.into_span().as_str();
  unreachable!("unexpected rule {:?} with content {}", rule, s)
}
這樣的實作會有個問題,在程式終止之後的位置一律都會在 parse_fail 這個函式裡,而不是真正出錯的剖析函式,要除錯必須開 stack trace 才能做到。為了避免這個狀況,我們改用 macro 實作 parse_fail,這樣 unreachable! 就會在出錯的位置展開,在終止程式的時候給出正確的位置。
關於 macro 小弟在很早的時候有寫過一篇貧乏的介紹文,改起來也很簡單,把原本作為函式參數傳進來的 pair,由 macro 的 $x:expr 取代,然後用 $x 取代本來 code 裡面所有的 pair,如下文:
macro_rules! parse_fail {
  ( $x:expr ) => {
    {
      let rule = $x.as_rule();
      let s = $x.into_span().as_str();
      unreachable!("unexpected rule {:?} with content {}", rule, s)
    }
  }
}
這樣所有程式裡的 parse_fail(pair) 就會自動開展成下面的三行程式碼了。

另外有一個要注意的是,假設我 parse_fail 的 macro 寫在模組的 helper.rs 裡面,那麼在寫模組的 lib.rs 時,mod helper 要在所有其他 mod 之前,並加上 #[macro_use] 修飾,這跟 rust 模組的編譯流程有關,macro 是跟順序有關的,在 mod helper 之後這個 parse_fail 的 macro 才有定義,後面的 mod 才能使用這個 macro,詳細可以參考這篇

如果想讓使用 extern crate 的人也能使用這個 macro,就要在定義 macro 的時候在前面加上 #[macro_export] 的標籤,每個 macro 都需要單獨 export 才行。

處理 expression 的正確姿勢:

如果有看上一篇,會看到我用 PEG 套件 pest 的 precedence climbing 的功能來完成對 expression 的剖析,但其實那是不完整的,原因在於我們把的做法是把 expression 直接導向 unary_expr (op_binary unary_expr)* 的組合,這樣我們看到 expression,把它展開來就可以得到一大串 unary_expr 跟 op_binary 交錯的序列,把這串東西丟進 precedence climbing 裡面就能建好 expression tree 了。
但實際上的 C 語言比這還要複雜,expression 下面還有 assignment expression,conditional expression 等等,這些 expression 是必須存在的,例如在變數 decl 的地方就會需要 assignment expression,我們本來的寫法把 assignment expression 等等都抹掉了要怎麼辦?把它們加回去要怎麼讓本來的 precedence climbing 的 code 還能正確運作?
後來發現的正確處理方法是這樣的,在文法的部分要把 assignment expression 等東西加回去,裡面用到的三元運算子 ?: ,assignment operator =, +=, -= 等等都從 op_binary 裡面排除,像是這樣:
logicalOR_expr = _{ unary_expr ~ (op_binary ~ unary_expr)* }
conditional_expr = _{ logicalOR_expr ~ ( op_qmark ~ silent_expression ~ op_colon ~ conditional_expr)? }
assignment_expr = _{ (unary_expr ~ op_assign)* ~ conditional_expr }
silent_expression = _{ assignment_expr ~ (op_comma ~ assignment_expr)* }
expression = { assignment_expr ~ (op_comma ~ assignment_expr)* }
原本的 expression 現在只剩 logicalOR_expr,其他的都要拉出來自立條目,讓其他的文法如 declaration 能使用它,但同時都使用 _{} 讓剖析後他們不會吐一個 node 出來,這樣看到 expression 之後,展開來仍然是一串 unary_expr 跟 operator 交錯的序列。

這樣做的好處是 precedence climbing 仍然可以沿用,所有的 operator 都算在 expression 頭下,壞處是我們必須依文法去調整一些文法要不要吐出 node,現在的實作在有兩個特例:
一個是如上面所示,conditional expression 的規定是 logical_OR_expression ? expression : conditional_expression,有一個 expression 在裡面,這會違反我們的假設:把 expression 展開來看都會是 unary_expr 跟 operator 的組合,因此我們要加上一個特別的 silent_expression 在剖析完之後不會生成 expression node ,而是完全展開。

另一個剛好是反過來的狀況,在 C 的 initializer 文法(6.7.9)是這樣定的:
initializer -> assignment_expression | "{" initializer-list "}"
但…我們的 assignment_expression 是不存在的,如果 initializer 真的剖析為 assignment_expression,展開 initializer 只會得到「一團 unary_expr 跟 operator 的組合」,會跟 initiailizer-list 搞在一起,所以反過來我新增了一個 initializer_expr,把 assignment expression 封起來:
initializer_expr -> assignment_expression
initializer -> initializer_expr | "{" initializer-list "}"
這樣拿到 initializer 就能放心展開,再看內容物是 initializer_expr 或 initializer-list 來決定下一步,如果是 initializer_expr 就能放心的丟給 precedence climbing 去建 expression 了。

上面兩個例子都沒什麼道理可言,基本上就是見招拆招,大致就是兩條好像在說廢話的規則:
  1. 會展開的 rule 裡面出現這團 rule 的開頭,則開頭的 rule 代換成自動展開的版本。
  2. 會展開的 rule 跟其他 rule 並列,要再多包一層不會展開的版本。

! tag for = and ==

在這次修改之前都沒什麼機會用到 ! tag,也就是 PEG 裡的 Not predicate,這次在處理更複雜的 expression 遇到,某些狀況 = 的優先權高過 == 以致 == 先被剖析成 = 了。
這時候 op_assign_eq 就要改為:
op_assign_eq = { "=" ~ !"=" }
來確保 = 之後沒有接著其他的 =。

comment

comment = _{ "/*" ~ (!"*/" ~ any)* ~ "*/" | "//" ~ (!"\n" ~ any)* ~ "\n" }
comment 也是這次的修改之一,同樣利用了 ! 的特性,上面兩條其實都滿直覺的:
開頭是 /* 再來只要不是 */ 的內容,就可以匹配任何字元;開頭是 // 再來只要不是換行就可以匹配任何字元。
這兩個例子都使用了 Not predicate,功能很類似 C 裡面的 peek,偷看一下後面的東西而不消耗任何東西。

Hidden grammar:

這點比較不是程式的問題,而是 C 規格的問題,注意以下這些都符合 C grammar,但在工作上千萬別這麼寫,大概有十成的機率你會被電到天上飛
  • volatile, restrict, const 隨便加,加幾個都沒關係
  • 其實可以不用 type,這是符合文法的,gcc 在這裡會直接給你一個 int。
  • 也可以宣告型別,儲存類型什麼的,最後…沒變數。
所以可以寫像是:
int const volatile const volatile const volatile const volatile const volatile const;
const * restrict restrict restrict a;
說真的,看到這樣寫 code 我也會把人電到天上飛,其實我也不知道為什麼 C grammar 要允許這樣的文法就是,看到 gcc 編譯過我差點笑死。

現在離大致完成還有一個最大的難關,就是 declaration 那邊還有 struct, union, enum 等著處理,文法上是還好,更大的問題是不知道怎麼寫轉出來的 AST,之前我大部分都參考強者我學長 suhorng 大大的 haskell 實作,或者參考一些 LLVM 的 IR 實作…當然是沒辦法到 LLVM 那麼複雜啦QQ。
總之最近進度嚴重卡關,這才是我為什麼在這裡打住寫篇文的原因(誒。

自己自幹 AST,配上最近工作上做的一些改動,讓我有了下面這個體會:
資訊源自於數學,本身是無窮的,正如數線上有無數的正整數,無窮的有理數,比無窮更無窮的無理數;數學這個「概念」本身就有無限的資訊
但有了電腦一切就不一樣了,我們只有有限的位元能夠近似數學的概念,所以就有了取捨。
用 64 位元可以表示到 18446744073709551615,大約是 10^19,於是 10^19 -> ∞ 的資訊就被捨去了;同理我們決定浮點數用 IEEE 754 表示,有些小數就是無法表示,無窮的資訊對上有限資源,其間的差距令人絕望。

就如我們把 C code parse 成 AST,AST 裡面要保留多少資訊?像我這樣基本上只保留了簡單的 AST node,隨便建顆樹而已;LLVM 的 IR 就是許多嚴僅設計的物件,保留程式語言的繼承關係跟內部的屬性設計,在處理上就有更多能運用的資訊。
工作上需要的是用電腦處理幾何的資訊,像是點、線、四邊形,那麼一個線段的物件要儲存什麼資訊?可以用起點終點來表示一條線,基於效率跟空間考量,我們可能可以存一下線段是不是垂直的、水平、甚至是不是斜上跟斜下,但要不要存一個 double 的斜率呢?這就要看平常是不是很常需要算斜率了。存更多的東西自然可以方便做些處理,但線段更新時也要更新更多的資訊。

捨去是面對資訊時的必要,資訊工程處理的問題一直都不是資訊太少而是資訊太多,而要捨去什麼資訊、保留什麼,這不是科學而是技藝,這些都不是數學,不會有一個標準的答案,而是視需求去選擇,需要經驗、工具、模擬、除錯、測試……用實驗跟說理得到一個最佳近似的解;正如大學學系的名字:資訊<工程>學系。

2018年8月25日 星期六

用 PEG 寫一個 C parser

故事是這樣子的,之前我們寫了一個自創的程式語言 Simple Language ,還用了 Rust 的 pest 幫他寫了一個 PEG parser,雖然說它沒有支援新加入的函式等等,本來想說如果年底的
MOPCON 投稿上的話就把它實做完,結果沒上,看來是天意要我不要完成它(欸

總而言之,受到傳說中的 jserv 大神的感召,就想說來寫一個複雜一點的,寫個 C language 的 PEG parser 如何?然後我就跳坑了,跳了才發現此坑深不見底,現在應該才掉到六分之一深界一層吧 QQQQ。
目前的成果是可以剖析 expression 並依照正確運算子順序處理,還有部分的 statement 也能正常剖析,因為通常能處理 expression 跟 statement 剖析裡麻煩的工作就完成 50 %,決定寫小文介紹一下,啊不過我還沒處理 expression 出現轉型的時候該怎麼辦,array reference 出現要怎麼辦,所以這部分可能還會改。
本來專案想取名叫 rcc 或 rucc,但這樣的專案名稱早就被其他人的 rust c compiler 用掉了,因為寫 Rust 的人好像都很喜歡用元素來當專案名稱,這個專案的名字就叫 Carbon 了XD。

其實寫這個跟寫 simple parser 沒什麼差,只是語法更加複雜,複雜之後就容易寫得沒有結構化;PEG 有個好處是在處理基礎的結構的時候,比起用 regular expression 各種複雜組合還要簡潔,像是 floating point 的時候,這是 C floating point 的語法,雖然這還不含 floating point 的 hex 表達式,但不負責任的臆測,要加上 hex 的支援只要多個 (decimal | hex) 的選擇就好了:
sign = { "+" | "-" }
fractional = { digit* ~ "." ~ digit+ | digit+ ~ "." }
exponent = { ("e" | "E") ~ sign? ~ digit+ }
floating_sfx = { "f" | "F" | "l" | "L" }
floating = @{ fractional ~ exponent? ~ floating_sfx? }

不過脫離基本結構,開始往上層架構走的時候麻煩就來了。
我的感想是,在大規模的語言剖析上 PEG 其實不是一個很好用的剖析方式,寫起來太隨興,沒有一套科學分析的方法告訴你怎麼樣寫比較好,甚至怎麼寫是對的,就連 C standard 的寫法都是用 CFG 可以處理的格式寫的,PEG 畢竟比較年輕才沒人鳥它;在傳統 CFG,List 就是用 List -> List x 那套來表達,在 PEG 裡卻可以直接把剖析的文法用 +, * 重複,層數相較 CFG 可以有效扁平化,相對應的壞處是很容易寫到壞掉,目前為止花了很大的心力在調整語法各部分的結構,非常耗費時間。

例如在剖析函式的內容,C 語法大概是這樣定義的:
compound_statement -> block_list
block_list -> block_list block
block -> declaration_list | statement_list
declaration_list -> declaration_list declaration
statement_list -> statement_list statement

上面這段大致體現了 declaration 跟 statement 交錯的寫法,一開始寫的時候,直譯就翻成
compound_statement -> block*
block -> declaration* ~ statement*

很直覺對吧?但上述的語法會直接進到無窮迴圈,上下層兩個連續的 * 或 + 是 PEG 語法的大忌,當上層跳入 * 無窮嘗試的時候,下層就算沒東西 match 了,照樣可以用 * 的空集合打發掉;同理上層是 + 下層是 * 也不行,理由相同;真正的寫法是上層用 * ,下層用 + ,在沒東西的時候由下層回傳無法匹配讓上層處理。
這個例子最後的寫法其實是這樣:
compound_statement -> block*
block -> (declaration | statement)+

或者是這樣,反正轉成 AST 之後也沒人在意 block 到底是只有 declaration 、只有 statement 還是兩個都有,乾脆把所有 declaration 跟 statement 都攪進一個 block 裡:
compound_statement -> block
block -> (declaration | statement)*

這個例子很明顯的體現出 PEG 文法的問題,透過文法加上 ?+*,我們可以很有效的把本來的 list 打平到一層語法,但連接數層的 +* 就需要花費時間調解層與層之間的融合,是一件複雜度有點高的事情。
很早之前在看參考資料的時候有看到這句,現在蠻有很深的體會:「我覺得用 PEG 類工具可以很快的擼出一個語法,是日常工作中的靠譜小助手,但要實現一個編程語言什麼的話,還得上傳統的那一套。」(註:原文為簡體中文此處直翻正體中文)
像是我的 simple parser 跟 regex parser,用 PEG 寫起來就很簡明,一到 C 這種複雜語言就頭大了;CFG 那邊的人大概會指著 PEG 的人說,你們 PEG 的人就是太自由了,哪像我們當年(誒

剖析寫完再來還要把文法轉譯成 AST。
在實作上大量參考強者我學長 suhorng 大大的 haskell C compiler 實作,想當初跟學長一起修 compiler,那時候我還很廢(其實現在也還是很廢),光是寫 C lex/yacc 能把作業寫出來不要 crash 就謝天謝地了,然後學長上課的時候 haskell 敲一敲筆電就把作業寫完了 QQQQ。

用 rust 的 pest PEG 套件寫轉換的程式,大部分都是 match rule ,看是哪種 Rule 之後去呼叫對應的函式來處理。
在expression 的部分可以直接使用 pest 提供的 precedence climbing 功能,無論是文法或建 AST 都很簡單,文法甚至可以收到一行,因為 expression 都是一樣的格式:
expression -> unary_expr (binary_op unary_expr)*
再到 precedence climbing 為所有 op 分出順序,就像 climb.rs 裡面那壯觀的 C operator 優先次序:
fn build_precedence_climber() -> PrecClimber<Rule> {
  PrecClimber::new(vec![
    Operator::new(Rule::op_comma,    Assoc::Left),
    Operator::new(Rule::op_assign_add,   Assoc::Right) |
    Operator::new(Rule::op_assign_sub,   Assoc::Right) |
    Operator::new(Rule::op_assign_mul,   Assoc::Right) |
    Operator::new(Rule::op_assign_div,    Assoc::Right) |
    Operator::new(Rule::op_assign_mod,  Assoc::Right) |
    Operator::new(Rule::op_assign_lsh,    Assoc::Right) |
    Operator::new(Rule::op_assign_rsh,    Assoc::Right) |
    Operator::new(Rule::op_assign_band, Assoc::Right) |
    Operator::new(Rule::op_assign_bor,    Assoc::Right) |
    Operator::new(Rule::op_assign_bxor,  Assoc::Right) |
    Operator::new(Rule::op_assign_eq,     Assoc::Right),
    Operator::new(Rule::op_qmark,    Assoc::Right) |
    Operator::new(Rule::op_colon,    Assoc::Right),
    Operator::new(Rule::op_or,       Assoc::Left),
    Operator::new(Rule::op_and,      Assoc::Left),
    Operator::new(Rule::op_bor,      Assoc::Left),
    Operator::new(Rule::op_bxor,     Assoc::Left),
    Operator::new(Rule::op_band,     Assoc::Left),
    Operator::new(Rule::op_eq,       Assoc::Left) |
    Operator::new(Rule::op_ne,       Assoc::Left),
    Operator::new(Rule::op_gt,       Assoc::Left) |
    Operator::new(Rule::op_lt,       Assoc::Left) |
    Operator::new(Rule::op_ge,       Assoc::Left) |
    Operator::new(Rule::op_le,       Assoc::Left),
    Operator::new(Rule::op_lsh,      Assoc::Left) |
    Operator::new(Rule::op_rsh,      Assoc::Left),
    Operator::new(Rule::op_add,      Assoc::Left) |
    Operator::new(Rule::op_sub,      Assoc::Left),
    Operator::new(Rule::op_mul,      Assoc::Left) |
    Operator::new(Rule::op_div,      Assoc::Left) |
    Operator::new(Rule::op_mod,      Assoc::Left),
  ])
}

match 之後一定有些規則是無法處理的,例如 match statement 的時候就不用管 op_binary 的 rule,這裡我寫了一個函式來承接這個規則,Rust 函式的 ! 型別相當於 C 的 noreturn,已經確定這個還是不會回傳值了,印出錯誤訊息後就讓程式崩潰;這個函式就能在任何 match 的 _ arm 上呼叫。
fn parse_fail(pair: Pair<Rule>) -> ! {
  let rule = pair.as_rule();
  let s = pair.into_span().as_str();
  unreachable!("unexpected rule {:?} with content {}", rule, s)
}
這樣這個函式就能出現在任何地方,例如 match 當中,每一條分支都應該要得到同樣的型別,不過這個函數可以是例外,畢竟它確定不會再回來了:
match (rule) {
  Rule::op_incr => Box::new(CastStmt::StmtPostfix(CastUnaryOperator::INCR, primary)),
  Rule::op_decr => Box::new(CastStmt::StmtPostfix(CastUnaryOperator::DECR, primary)),
  _ => parse_fail(pair),
}
我自己是覺得,把 PEG 文法剖析出來的結果轉換到 AST 上面,麻煩程度差不多就跟寫一個 recursive descent parser 差不多,而且用了 PEG 套件很難在使用者給了不正確程式時,給出有意義的錯誤訊息,我用的 pest 最多只能指著某個 token 大叫不預期這個 token ,預期要是哪些哪些。
到頭來要給出好一點的錯誤訊息跟發生錯誤的回歸能力,也許還真的只能像 gcc, llvm 一樣,直接幹一個 recursive descent parser。

不過以下是我不負責任的想法,我我暗暗覺得 PEG 的語法和 recursive descent parser 之間應該有某種對應的關係,也就是說,如果設計好 PEG ,應該能給出一個不錯的 recursive descent parser 的骨架,搭配使用者設計好在哪些文法遇到哪些錯誤該如何處理函式群,生出一個 recursive descent parser 來;不過以上只是不負責任的臆測,請各位看倌不要太當真。

這個專案其實還在草創階段,還有超多東西沒有支援(其實連個像樣的功能都沒有吧...),各位看倌大大手下留情QQQQ

下一步我也還沒想好怎麼做,之前有看到 rucc 的專案,是直接使用 rust 跟 LLVM 組合的套件,把剖析的程式碼直接轉成 LLVM IR,也許可以參考這種做法也說不定。


2018年8月17日 星期五

寫了一個不知道幹什麼用的 regex library 跟 parser

故事是這樣子的,之前寫 understanding computation 的時候,發現 regular expression 的實作,只有最基本的五種:empty 匹配空字串,literal 匹配文字、repeat * 匹配零個或多個 regex、concatenate 匹配連續的 regex、choose 匹配嘗試數個 regex。
大約幾天前想到也許可以把這個 project 拓展一些,讓它威力更強力一點點,順便當個練習。

預計要加入的東西有:
  • 支援 +, ? 兩種擴展的重複方式。
  • 支援 “.” 匹配 any character。
  • 支援 "[]", "[^]" 匹配在/不在集合中的字元。

+跟? 是最簡單的,它們和狀態機的設計無關,只要修改產生狀態機的方式即可;"*" 的時候是引入一個 start_state,將 start_state 和原本狀態機的 accept_state 加入新的 accept_state,並用 free_move 將 accept_state 跟原本狀態機的 start_state 連起來。
+ 的話是新的 start_state 不會是 accept_state 的一員。
? 的話是原本狀態機的 accept_state 不會用 free_move 和 start_state 連線。

Any 相對也很好做,本來我們的 farule 是一個 struct ,現在變成一個帶 enum type 的 struct,該有的都有:一個 state 到另一個 state;取決於 enum type ,會使用不同的 match 規則來決定適不適用規則。
像是 Any 就是全部都適用,literal char 的話會是比較一下字元是否相同。

[] 的實作…我發現到之前的實作,不太容易實作出 [^] 的規則。
[] 相對容易,簡單看來它就只是把裡面所有的字元變成 choose rule,例如 [a-z] -&gt; a|b|c..|z,但 [^] 就不同了,因為在 NFA 裡面,一個輸入的字元會跟所有可以嘗試的 rule 去匹配,但我們不可能把 [^] 之外所有的字元都接一個 choose 去 accept state (好吧理論上可以),如果在 [^a-z] 把 a-z 接去一個 dummy state, 用一個 any rule 接去 accept state,表示任何輸入的字元在嘗試 any rule 的時候都會通過它漏到 accept state 去。
最後還是在一條 rule set 裡面,保留所有可匹配字元的方式解決 QQ,這樣一來 match 一個字元的 rule 就變成這個 rule 的子集(不過為了方便還是保留這個 rule),接著 regex 的 [] [^] 就只是直接轉譯成 Rule set 就行了。

剩下的應該就只有改一下 PEG parser,這部分比較沒什麼,其實寫這個 project 沒什麼突破性的部分,大部分都是做苦工,好像沒什麼好值得說嘴的。
而且這樣改還有一個後遺症,就是匹配變得只能在 NFA 下進行,不能轉成 DFA 來做了,因為如 rule any, rule set 都會讓我們無法走訪目前所有可能的匹配結果,目前我看到市面上(?) 的 regular expression 實作,也都不是用這種 NFA 的方式在實作的,所以說我寫這個 project 到底是為了什麼……。

寫文章附上原始碼是常識我知道我知道:
https://github.com/yodalee/nfa_regex

當然還是有一些好玩的東西,例如測試可以任我寫:
https://github.com/yodalee/nfa_regex/blob/master/src/regular_expressions/mod.rs#L132

2018年7月27日 星期五

把 NFA 轉成 DFA 這檔事

故事是這樣子的,最近在時寫一些跟 Regex 相關的程式碼,無意間發現我之前understanding computation 這本書的實作中,並沒有實作非確定有限自動機(下稱 NFA)轉成有限自動機 的程式碼。

所以說我們這次就來實作一下。

概念很簡單,我們手上有一個,我們可以把這個 NFA 拿來,從開始狀態開始,將這個狀態可以接受的字元都走走看,把每一個字元輸入之後的狀態組合都記下來(因為是NFA,同時可以存在許多狀態上),重複這個流程直到所有狀態組合都被記錄過之後, 就能產生對應的 DFA,這個新的 DFA 每一個狀態都會原本 NFA 狀態的組合。
這也代表:其實 NFA 並沒有比 DFA 更「高級」的能力,一切都可以用 DFA 來完成,NFA 只是在表示跟建構上,比 DFA 方便一些而已。

實作一樣使用 Rust 的實作,雖然說也只是照著書上的 ruby code 實作就是了。
在這之前我們已經完成了 FARule, NFARulebook, NFA, NFADesign 等一系列的 struct ,用上了 Rust 的泛型,確保 FARule 可以接受任何型別作為狀態,無論是整數還是一個 struct,這點很重要,這樣我們後面的修改都不用再擔心狀態型別的問題。

首先我們先創了一個 NFASimulation 的 struct,把之前已經實作完成的 NFADesign 塞進去,並加上一個新的函式 to_nfa_with_states,接受一個 NFA 狀態為參數,產生一個以這個狀態為初始狀態的 NFA (一般 NFADesign 產生的 NFA 的初始狀態是固定的)。
接著我們在 NFASimulation 實作兩個函式:next_states 和 rule_for:
  • next_states 會拿到一個 NFA 狀態(HashSet<T>)跟一個字元,回傳的是接受這個字元之後,NFA 新的狀態,注意所謂 NFA 的狀態意思是 DFA 下狀態的組合,因此型態是 HashSet<T>。
  • rule_for 參數是 NFA 狀態,嘗試 NFA 中所有可能的輸入字元,回傳從這個狀態出發,所有可能的規則(FARule)。
最後把上面兩個函式結合在一起,實作 discover_states_and_rules 函式,拿到只有一個初始狀態的集合之後,先呼叫 rule_for 得到所有從這個狀態出發的 rule,將每個 rules 指向的狀態取出來;如果新的狀態集合是輸入狀態集合的子集,表示所有可能的字元輸入都不會得到新的狀態,我們就已經遍歷整個 NFA 了;否則,再把這個狀態集合丟進 discover_states_and_rules 裡面,遞迴直到收集到所有狀態為止。

話是這麼說,但開始實作之後就遇上一個問題,NFA 狀態表示是 HashSet<T>,discover_states_and_rules 的輸入就是 HashSet<HashSet<T>> (NFA 狀態的集合);但 Rust 並不支援 HashSet<HashSet<T>> 這樣的泛型,當我試著從 discover_states_and_rules,設定回傳型別是 HashSet<HashSet<T>> ,rustc 立刻就噴我一臉錯誤,rustc 預設並沒有 HashSet<T> 的雜湊方式,也因此無法把它作為 HashSet 的 key。

當然這是有解法的,後來是參考了下面這個連結
不能直接使用 HashSet<HashSet<T>>,得先把 HashSet<T> 封裝起來,放到名為 StateSet 的 tuple struct 當中,tuple struct 的角色就是單純的封裝,可以用 struct.0 取得第 0 個封裝的元素,也就是封裝的 HashSet<T>。
如此一來我們就可以實作一個 StateSet<T> 使用的雜湊方式,說出來沒啥特別,就是把整個 set 裡面的元素拿出來再雜湊一遍就是了,下面是相關的實作:
use std::hash::{Hash, Hasher}
impl<T: Eq + Clone + Hash + Ord> Hash for StateSet<T> {
  fn hash<H>(&self, state: &mut H) where H: Hasher {
    let mut a: Vec<&T> = self.0.iter().collect();
    a.sort();
    for s in a.iter() {
      s.hash(state);
    }
  }
}

將 HashSet<T> 封裝成 StateSet 之後,就能使用 HashSet<StateSet<T>> 表示 NFA 狀態的集合,從狀態集合到狀態集合的規則,也能用 FARule<StateSet<T>> 表示,這樣就能夠完成 discover_states_and_rules 函式,其返回值是 (states, rules),即型別 (HashSet<StateSet<T>>, Vec<FARule<StateSet<T>>>)。
利用 discover_states_and_rules 一口氣拿到一個 NFA 所有可能的狀態集合,還有對應的規則,就可以產生一個對應的 DFA。
pub fn to_dfa_design(&self) -> DFADesign<StateSet<T>> {
  let start_state = self.nfa_design.to_nfa().current_state();
  let mut start_set = HashSet::new();
  start_set.insert(StateSet::new(&start_state));
  let (states, rules) = self.discover_states_and_rules(&mut start_set);
  let accept_state = states.into_iter()
    .filter(|state| self.nfa_design.to_nfa_with_state(&state.0).accepting())
    .collect();
  DFADesign::new(StateSet::new(&start_state), &accept_state, &DFARulebook::new(rules))
}

在寫這一段程式碼的時候,因為是看著書中的 Ruby 範例程式碼在寫, 其實有很多地方的程式碼還滿像的,例如在 discover_states_and_rules 裡面,除了在型別上面卡關非常久之外,其他語法跟 Ruby 幾乎沒有什麼差別,真不虧是Rust,也許該私下稱 Rust 為「強型別的 Ruby/Python 」XD。
不過呢,在 HashSet<HashSet<T>> 的問題上面,還有例如將兩個 HashSet<T> union 起來上,還是被 rustc 嗆了非常久,真的是深切體會 Python, Ruby 等等知名語言背後到底幫我們處理多少髒東西。

這篇文章相關的原始碼可見:
https://github.com/yodalee/computationbook-rust/blob/master/src/the_simplest_computers/finite_automata/nfasimulation.rs

心得一:我真的覺得,寫 Rust 的話,一定要記得下個 :set matchpairs+=<:> 開啟 <> 的 match,不然看看上面那複雜的嵌套,眼睛直接花掉。
心得二:寫過 Rust 之後再寫 C++ ……,這又醜又囉唆的語言是哪裡冒出來的,超級不習慣,不過就我目前的一點…直覺,我覺得 C++ 如果把過去的東西都拋掉,把 std、reference、smart pointer 用到極致,就會得到一個很像 Rust 但沒有 Rust 記憶體檢查等功能的語言。
心得三:Rust 是世界上最好的程式語言
心得四:頑張るビー (誤