12/08/2018, 12:20

[Ruby] Tạo một Hash với chiều sâu vô hạn

Đọc và dịch theo ý hiểu của bài viết sau http://firedev.com/posts/2015/bottomless-ruby-hash/ Vào những ngày khác nhau, có nhiều người hỏi rằng nếu có một cách mù quáng nào đó để gán giá trị lồng nhau cho Ruby Hash mà không càn tạo từng key. Hóa ra là có, và nó có những thú vị, tuy nhiên những ...

Đọc và dịch theo ý hiểu của bài viết sau http://firedev.com/posts/2015/bottomless-ruby-hash/

Vào những ngày khác nhau, có nhiều người hỏi rằng nếu có một cách mù quáng nào đó để gán giá trị lồng nhau cho Ruby Hash mà không càn tạo từng key. Hóa ra là có, và nó có những thú vị, tuy nhiên những thú vị đó có thể gây ra các tác dụng phụ. Chào mừng đến với Bottomless Hash.

Đầu tiên, chúng ta hãy thử theo cách thông thường khi mà gán giá trị vào một Hash.

params = {}
params[:world][:vietnam] = :hanoi
=> NoMethodError: undefined method `[]=' for nil:NilClass

Như các bạn đã thấy, chúng sẽ không thể hoạt động và raise ra lỗi như trên. May mắn thay, Hashes trong Ruby có thể khởi tạo với giá trị mặc định ban đầu. Điều đầu tiên là hãy thử nó, trông nó khá là rõ ràng. Hãy khởi tạo một Hash mới và để giá trị mặc định ban đầu là một hash rỗng.

params = Hash.new({})
params[:world][:vietnam] = :hanoi

params[:world]
=> {:vietnam=> :hanoi}

Trông nó có vẻ hợp lý. Nhưng chúng ta hãy đào sâu thêm một chút. Ta khởi tạo một giá trị mới cho hash vừa rồi như sau

params[:world][:thailand] = :bangkok

Bây giờ hãy thử xem params có những giá trị nào:

params
=> {}

Tại sao nó lại trả về kết quả rỗng như vậy??? Ta hãy add thêm một số key khác vào trong nó.

params[:underworld] = :hell

Và kiểm ra lại params

params
=> {:underworld=>:hell}

Chuyện gì đang xảy ra vậy? Đã có một phép thuật xấu xa nào ở đây à? Không hẳn là như vậy. Đầu tiên :world key được khởi tạo với giá trị mặc định là một Hash rỗng. Nó rất dễ dàng để truy cập, Vì một hash vẫn được trả về khi mà nó không có key nào. Tuy nhiên tất cả thành phố của chúng ta đều có giá trị trong cả 2 thế giới (:world, và :underworld) chúng ta đã khởi tạo

params[:world][:thailand]
=> :bangkok

params[:underworlds][:vietnam]
=> :hanoi

Vâng, chúng ta đã biết và cần phải fix nó. Khởi tạo một Hash mới cho các giá trị, chúng ta cần vượt qua được một block, block đó chấp nhận 2 biến - một là Hash cho chính nó, và key để truy cập tới nó. Bây giờ hãy khởi tạo Hash rỗng với key và giá trị cho nó.

params = Hash.new do |hash, key|
  hash[key] = Hash.new
end

params[:world][:thailand]=:phuket

Bây giờ hãy kiểm tra lại giá trị của hash mới khởi tạo

params
=> {:world=>{:thailand=>:phuket}}

Thật tuyệt vời đúng không nào. Okay, nhưng mà chuyện gì sẽ xảy ra nếu chúng ta add thêm một level mới?

params[:asia][:thailand][:bangkok] = :chao_praya
=> NoMethodError: undefined method `[]=' for nil:NilClass

Oh không, không phải một lần nữa chứ. Chúng ta có thể làm gì giờ? Hãy add thêm một tầng mới cho hash khởi tạo. Vì vậy các Hash nồng nhau có thể lần lượt tạo thêm hashes:

params = Hash.new do |hash0, key0|
  hash0[key0] = Hash.new do |hash1, key1|
    hash1[key1] = Hash.new
  end
end

params[:asia][:thailand][:moscow] = :moscow_river

Nó hoạt động, nhưng điều gì xảy ra nếu ta add thêm chiều sâu cho hash đó?

params[:asia][:thailand][:bangkok][:river] = :chao_praya
=> NoMethodError: undefined method `[]=' for nil:NilClass

Okay, bây giờ chúng ta cần giải quyết điều này một lần và cho tất cả. Bây giờ chúng ta hãy gộp các chức năng cần thực hiện vào một function và sau đó đưa chúng tới nơi mà chúng ta cần sử dụng. Điều chúng ta cần là một procedure(thủ tục), nó sẽ trả về một hash mới với procedure giống trước đó, được ẩn bên trong chờ đợi cho một key mới sẽ được khởi tạo.

Cái trình tự đó trông sẽ như thế nào? Nó khá là quen thuộc với thực tế. Chúng ta chỉ cần đóng gói nó vào một lambda và dùng biểu tượng & để đẩy nó vào trong Hash khi khởi tạo.

procedure = lambda do |hash, key|
  hash[key] = Hash.new(procedure)
end

params = Hash.new(&procedure)
params[:russia][:moscow] = :moscow_river
params
=> {:russia=>{:moscow=>:moscow_river}}

Okay vấn đề đó đã được giải quyết, bây giờ ta sẽ làm cho nó chặt chẽ hơn => chúng ta sẽ không cần phải tạo một lambda trước khi khởi tạo một Hash. Ruby Hash có một phương thức là default_proc, phương thức này cho phép chúng ta truy cập vào các block hash khi khởi tạo Hash.

params = Hash.new {|h, k| h[k] = Hash.new(&h.default_proc)}

params[:world][:thailand][:bangkok][:bangna]
params
=> {:world=>{:thailand=>{:bangkok=>{:bangna=>{}}}}}

Nó thật tuyệt vời đúng không, nhưng quan điểm thực tế của một hash không đáy là gì? Điểm thú vị mà nó gây ra ở đây là: nó sẽ không bao giờ bị lỗi khi bạn đọc một giá trị nào đó.

params[:i][:dont][:know]
=> {}

Và vẻ đẹp của nó là, bạn có thể sát nhập bất kì hash nào đó với nó để sản xuất ra một phiên bản hash không đáy. Vì vậy bạn có thể truy cập một cách mù quáng đến các keys của hash.

unknown = { key: :value }
bottomless = params.merge unknown

bottomless[:missing][:value]
=> {}

Không có câu trả lời cho câu hỏi, độ dài của chuỗi sẽ là bao nhiêu, Hash không đáy sẽ không raise ra một lỗi nào. Nó trả về một hash rỗng thay vì giá trị nil, cái nào là sự thật1? Nhưng nó có thể được check với function empty? ngay cả trong ruby đơn thuần.

Như đã được đề cập trước đó, chúng ta có thể đóng gói các hành vi vào một Class và nó sẽ trả về cho chúng ta một Bottomless rỗng (hash không đáy rỗng) hoặc nó sẽ chuyển đổi một hash sang một phiên bản bottomless mới.

class BottomlessHash < Hash
  def initialize
    super &-> h, k {h[k] = self.class.new}
  end

  def self.from_hash(hash)
    new.merge(hash)
  end
end

Bạn có thể tham khảo một số test function dưới đây:

class BottomlessHash < Hash
  def initialize
    super &-> h, k { h[k] = self.class.new }
  end

  def self.from_hash(hash)
    new.merge(hash)
  end
end

class Hash
  def bottomless
    BottomlessHash.from_hash(self)
  end
end

Test

describe BottomlessHash do
  subject { described_class.new }

  it 'does not raise on missing key' do
    expect do
      subject[:missing][:key]
    end.to_not raise_error
  end

  it 'returns an empty value on missing key' do
    expect(subject[:missing][:key]).to be_empty
  end

  it 'stores and returns keys' do
    subject[:existing][:key] = :value
    expect(subject[:existing][:key]).to eq :value
  end

  describe '#from_hash' do
    let (:hash) do
      { existing: { key: { value: :hello } } }
    end

    subject do
      described_class.from_hash(hash)
    end

    it 'returns old hash values' do
      expect(subject[:existing][:key][:value]).to eq :hello
    end

    it 'provides a bottomless version' do
      expect(subject[:missing][:key]).to be_empty
    end

    it 'stores and returns new values' do
      subject[:existing][:key] = :value
      expect(subject[:existing][:key]).to eq :value
    end

    it 'converts nested hashes as well' do
      expect do
        subject[:existing][:key][:missing]
      end.to_not raise_error
    end
  end
end

=> Bottomless là tiện cho việc xử lý cũng như là khi giao dịch với các cấu trúc lồng nhau từ thế giới bên ngoài, nhưng mà cũng có một điểm đó là, khi mà bạn gọi một key không có trong hash => nó sẽ mặc định thêm key đó vào hash của bạn (điều này thực sự cũng không có ảnh hưởng gì nhiều đến code của bạn.)

0