Add CombinedMapView (#53)

* Add CombinedMapView.

* Mistype.

* Address feedback.

* Address feedback.
diff --git a/lib/collection.dart b/lib/collection.dart
index 7012432..70f9fbc 100644
--- a/lib/collection.dart
+++ b/lib/collection.dart
@@ -6,6 +6,7 @@
 export "src/canonicalized_map.dart";
 export "src/combined_wrappers/combined_iterable.dart";
 export "src/combined_wrappers/combined_list.dart";
+export "src/combined_wrappers/combined_map.dart";
 export "src/comparators.dart";
 export "src/equality.dart";
 export "src/equality_map.dart";
diff --git a/lib/src/combined_wrappers/combined_iterable.dart b/lib/src/combined_wrappers/combined_iterable.dart
index 62e02be..511876e 100644
--- a/lib/src/combined_wrappers/combined_iterable.dart
+++ b/lib/src/combined_wrappers/combined_iterable.dart
@@ -21,8 +21,10 @@
   Iterator<T> get iterator =>
       new _CombinedIterator<T>(_iterables.map((i) => i.iterator).iterator);
 
-  // Special cased isEmpty/length since many iterables have an efficient
-  // implementation instead of running through the entire iterator.
+  // Special cased contains/isEmpty/length since many iterables have an
+  // efficient implementation instead of running through the entire iterator.
+
+  bool contains(Object element) => _iterables.any((i) => i.contains(element));
 
   bool get isEmpty => _iterables.every((i) => i.isEmpty);
 
diff --git a/lib/src/combined_wrappers/combined_map.dart b/lib/src/combined_wrappers/combined_map.dart
new file mode 100644
index 0000000..8c2760b
--- /dev/null
+++ b/lib/src/combined_wrappers/combined_map.dart
@@ -0,0 +1,52 @@
+// Copyright (c) 2017, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:collection';
+
+import 'combined_iterable.dart';
+
+/// Returns a new map that represents maps flattened into a single map.
+///
+/// All methods and accessors treat the new map as-if it were a single
+/// concatenated map, but the underlying implementation is based on lazily
+/// accessing individual map instances. In the occasion where a key occurs in
+/// multiple maps the first value is returned.
+///
+/// The resulting map has an index operator (`[]`) and `length` property that
+/// are both `O(maps)`, rather than `O(1)`, and the map is unmodifiable - but
+/// underlying changes to these maps are still accessible from the resulting
+/// map.
+class CombinedMapView<K, V> extends UnmodifiableMapBase<K, V> {
+  final Iterable<Map<K, V>> _maps;
+
+  /// Create a new combined view into multiple maps.
+  ///
+  /// The iterable is accessed lazily so it should be collection type like
+  /// [List] or [Set] rather than a lazy iterable produced by `map()` et al.
+  CombinedMapView(this._maps);
+
+  V operator [](Object key) {
+    for (var map in _maps) {
+      // Avoid two hash lookups on a positive hit.
+      var value = map[key];
+      if (value != null || map.containsKey(value)) {
+        return value;
+      }
+    }
+    return null;
+  }
+
+  /// The keys of [this].
+  ///
+  /// The returned iterable has efficient `length` and `contains` operations,
+  /// based on [length] and [containsKey] of the individual maps.
+  ///
+  /// The order of iteration is defined by the individual `Map` implementations,
+  /// but must be consistent between changes to the maps.
+  ///
+  /// Unlike most [Map] implementations, modifying an individual map while
+  /// iterating the keys will _sometimes_ throw. This behavior may change in
+  /// the future.
+  Iterable<K> get keys => new CombinedIterableView<K>(_maps.map((m) => m.keys));
+}
diff --git a/test/combined_wrapper/map_test.dart b/test/combined_wrapper/map_test.dart
new file mode 100644
index 0000000..c447373
--- /dev/null
+++ b/test/combined_wrapper/map_test.dart
@@ -0,0 +1,55 @@
+// Copyright (c) 2017, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:collection/collection.dart';
+import 'package:test/test.dart';
+
+import '../unmodifiable_collection_test.dart' as common;
+
+void main() {
+  var map1 = const {1: 1, 2: 2, 3: 3};
+  var map2 = const {4: 4, 5: 5, 6: 6};
+  var map3 = const {7: 7, 8: 8, 9: 9};
+  var concat = {}..addAll(map1)..addAll(map2)..addAll(map3);
+
+  // In every way possible this should test the same as an UnmodifiableMapView.
+  common.testReadMap(concat, new CombinedMapView(
+      [map1, map2, map3]
+  ), 'CombinedMapView');
+
+  common.testReadMap(concat, new CombinedMapView(
+      [map1, {}, map2, {}, map3, {}]
+  ), 'CombinedMapView (some empty)');
+
+  test('should function as an empty map when no maps are passed', () {
+    var empty = new CombinedMapView([]);
+    expect(empty, isEmpty);
+    expect(empty.length, 0);
+  });
+
+  test('should function as an empty map when only empty maps are passed', () {
+    var empty = new CombinedMapView([{}, {}, {}]);
+    expect(empty, isEmpty);
+    expect(empty.length, 0);
+  });
+
+  test('should reflect underlying changes back to the combined map', () {
+    var backing1 = <int, int>{};
+    var backing2 = <int, int>{};
+    var combined = new CombinedMapView([backing1, backing2]);
+    expect(combined, isEmpty);
+    backing1.addAll(map1);
+    expect(combined, map1);
+    backing2.addAll(map2);
+    expect(combined, new Map.from(backing1)..addAll(backing2));
+  });
+
+  test('should reflect underlying changes with a single map', () {
+    var backing1 = <int, int>{};
+    var combined = new CombinedMapView([backing1]);
+    expect(combined, isEmpty);
+    backing1.addAll(map1);
+    expect(combined, map1);
+  });
+}