Enable hashCode memoization for frozen protos. (#355)

diff --git a/protobuf/CHANGELOG.md b/protobuf/CHANGELOG.md
index b2c0c07..f9c1bf9 100644
--- a/protobuf/CHANGELOG.md
+++ b/protobuf/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 1.0.3
+
+* Enable hashCode memoization for frozen protos.
+
 ## 1.0.2
 
 * Fix hashcode of bytes fields.
diff --git a/protobuf/lib/src/protobuf/field_set.dart b/protobuf/lib/src/protobuf/field_set.dart
index 4fd47c2..cb4b376 100644
--- a/protobuf/lib/src/protobuf/field_set.dart
+++ b/protobuf/lib/src/protobuf/field_set.dart
@@ -23,8 +23,23 @@
 ///
 /// If the handler returns normally, the modification is allowed, and execution
 /// proceeds as if the message was writable.
-FrozenMessageErrorHandler frozenMessageModificationHandler =
+FrozenMessageErrorHandler _frozenMessageModificationHandler =
     defaultFrozenMessageModificationHandler;
+FrozenMessageErrorHandler get frozenMessageModificationHandler =>
+    _frozenMessageModificationHandler;
+set frozenMessageModificationHandler(FrozenMessageErrorHandler value) {
+  _hashCodesCanBeMemoized = false;
+  _frozenMessageModificationHandler = value;
+}
+
+/// Indicator for whether the FieldSet hashCodes can be memoized.
+///
+/// HashCode memoization relies on the [defaultFrozenMessageModificationHandler]
+/// behavior--that is, after freezing, field set values can't ever be changed.
+/// This keeps track of whether an application has ever modified the
+/// [FrozenMessageErrorHandler] used, not allowing hashCodes to be memoized if
+/// it ever changed.
+bool _hashCodesCanBeMemoized = true;
 
 /// All the data in a GeneratedMessage.
 ///
@@ -35,7 +50,6 @@
   final GeneratedMessage _message;
   final BuilderInfo _meta;
   final EventPlugin _eventPlugin;
-  bool _isReadOnly = false;
 
   /// The value of each non-extension field in a fixed-length array.
   /// The index of a field can be found in [FieldInfo.index].
@@ -48,6 +62,35 @@
   /// Contains all the unknown fields, or null if there aren't any.
   UnknownFieldSet _unknownFields;
 
+  /// Encodes whether `this` has been frozen, and if so, also memoizes the
+  /// hash code.
+  ///
+  /// Will always be a `bool` or `int`.
+  ///
+  /// If the message is mutable: `false`
+  /// If the message is frozen and no hash code has been computed: `true`
+  /// If the message is frozen and a hash code has been computed: the hash
+  /// code as an `int`.
+  Object _frozenState = false;
+
+  /// Returns the value of [_frozenState] as if it were a boolean indicator
+  /// for whether `this` is read-only (has been frozen).
+  ///
+  /// If the value is not a `bool`, then it must contain the memoized hash code
+  /// value, in which case the proto must be read-only.
+  bool get _isReadOnly => _frozenState is bool ? _frozenState : true;
+
+  /// Returns the value of [_frozenState] if it contains the pre-computed value
+  /// of the hashCode for the frozen field sets.
+  ///
+  /// Computing the hashCode of a proto object can be very expensive for large
+  /// protos. Frozen protos don't allow any mutations, which means the contents
+  /// of the field set should be stable.
+  ///
+  /// If [_frozenState] contains a boolean, the hashCode hasn't been memoized,
+  /// so it will return null.
+  int get _memoizedHashCode => _frozenState is int ? _frozenState : null;
+
   // Maps a oneof decl index to the tag number which is currently set. If the
   // index is not present, the oneof field is unset.
   final Map<int, int> _oneofCases;
@@ -122,7 +165,7 @@
 
   void _markReadOnly() {
     if (_isReadOnly) return;
-    _isReadOnly = true;
+    _frozenState = true;
     for (var field in _meta.sortedByTag) {
       if (field.isRepeated) {
         final entries = _values[field.index];
@@ -592,7 +635,14 @@
   ///
   /// The hash may change when any field changes (recursively).
   /// Therefore, protobufs used as map keys shouldn't be changed.
+  ///
+  /// If the protobuf contents have been frozen, and the
+  /// [FrozenMessageErrorHandler] has not been changed from the default
+  /// behavior, the hashCode can be memoized to speed up performance.
   int get _hashCode {
+    if (_hashCodesCanBeMemoized && _memoizedHashCode != null) {
+      return _memoizedHashCode;
+    }
     // Hashes the value of one field (recursively).
     int hashField(int hash, FieldInfo fi, value) {
       if (value is List && value.isEmpty) {
@@ -640,6 +690,10 @@
     if (_hasUnknownFields) {
       hash = _HashUtils._combine(hash, _unknownFields.hashCode);
     }
+
+    if (_isReadOnly && _hashCodesCanBeMemoized) {
+      _frozenState = hash;
+    }
     return hash;
   }
 
diff --git a/protobuf/pubspec.yaml b/protobuf/pubspec.yaml
index c5517d4..288f0a5 100644
--- a/protobuf/pubspec.yaml
+++ b/protobuf/pubspec.yaml
@@ -1,5 +1,5 @@
 name: protobuf
-version: 1.0.2
+version: 1.0.3
 description: >
   Runtime library for protocol buffers support.
   Use https://pub.dartlang.org/packages/protoc_plugin to generate dart code for your '.proto' files.
diff --git a/protobuf/test/readonly_message_test.dart b/protobuf/test/readonly_message_test.dart
index 81cdb42..b6fab67 100644
--- a/protobuf/test/readonly_message_test.dart
+++ b/protobuf/test/readonly_message_test.dart
@@ -92,6 +92,22 @@
     }
   });
 
+  test('eagerly computes hashCode if a custom read-only handler is used', () {
+    try {
+      final message = Rec.getDefault();
+      final initialHashCode = message.hashCode;
+
+      frozenMessageModificationHandler =
+          (String messageName, [String methodName]) {};
+      message.setField(1, 456);
+      final modifiedHashCode = message.hashCode;
+      expect(initialHashCode == modifiedHashCode, isFalse);
+    } finally {
+      frozenMessageModificationHandler =
+          defaultFrozenMessageModificationHandler;
+    }
+  });
+
   test("can't clear a read-only message", () {
     expect(
         () => Rec.getDefault().clear(),