-
-
Notifications
You must be signed in to change notification settings - Fork 94
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
strmap: add S.insert and S.remove #435
Conversation
We may want to consider adding |
Lovely pull request, @samueller! I see that you took care to follow the project's conventions. 🎉
I agree. I'd been thinking Scott, please add |
index.js
Outdated
//# insert :: String -> a -> StrMap a -> StrMap a | ||
//. | ||
//. Inserts a given property key and value into a shallow clone of a given | ||
//. string map. Overrides the property if it already exists. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's avoid using the term property in relation to string maps. The fact that string maps are unadorned objects is an implementation detail in most contexts.
Adding the following would help those coming from Ramda who are likely to expect the function to be named assoc
:
Equivalent to Haskell's insert
function. Similar to Clojure's assoc
function.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense. I'll remove "property" and add your 2 sentences.
@davidchambers, @samueller I'm rethinking They both rely on
|
Okay. |
@davidchambers, @gabejohnson. So I'll add |
👍 |
I created the
I'm guessing this is a problem because |
We have to change it. Calling it We could call it I apologize. This should have occurred to me before I was suggesting it. |
I like |
index.js
Outdated
//. string map. The original string map is returned if the key doesn't exist. | ||
//. | ||
//. Equivalent to Haskell's `delete` function. Similar to Clojure's `dissoc` | ||
//. functiom. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
s/functiom/function/
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
good catch!
index.js
Outdated
//. {} | ||
//. ``` | ||
function remove(key, strMap) { | ||
if (key in strMap) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is unsafe due to inherited properties. 'toString' in {}
, for example, evaluates to true
.
Is this implementation suitable?
var result = Z.concat(strMap, {});
delete result[key];
return result;
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wasn't aware of the inherited properties issue. The initial thought was to use concat
in the way you wrote. But it bothered me to add a property to an object and then delete it. In addition, I liked the efficiency of Haskell's delete
function where it returns the original Map
if the key didn't exist.
What about if (strMap.hasOwnProperty(key))
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What about
if (strMap.hasOwnProperty(key))
?
This would get us a bit closer, but we must allow for the possibility of 'hasOwnProperty'
being one of the keys. We'd need Object.prototype.hasOwnProperty.call(strMap, key)
.
I think we should match the semantics of Z.concat
, though, which is interested in enumerable properties rather than own properties. For example:
function Point() {}
Point.prototype.x = 0;
Point.prototype.y = 0;
Z.concat(new Point(), {}); // => {x: 0, y: 0}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch on the 'hasOwnProperty'
as a key issue.
I understand Z.concat
uses for..in to iterate through keys of enumerable and inherited enumerable properties. We could useObject.prototype.propertyIsEnumerable.call(strMap, key)
, but that will only tell us if a property is enumerable. It will return false
if a property is inherited enumerable as in your Point
example above. So, if we want to stick with returning the original string map when it doesn't contain the key to remove, we need to iterate through the keys before determining whether we can simply return the original string map:
function remove(key, strMap) {
var found = false;
var result = {};
for (var k in strMap) {
if (k === key) {
found = true;
} else {
result[k] = strMap[k];
}
}
if (found) {
return result;
} else {
return strMap;
}
}
If optimizing on removing non-existing keys more often, we could search for the key first before copying.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if (found) { return result; } else { return strMap; }
If we're to do the work to generate result
, why not return it in all cases? Sanctuary users, ideally, will not care about object identity, so it shouldn't be necessary to have S.remove(key, strMap) === strMap
evaluate to true
in certain cases. It may, though, be useful to guarantee that this expression never evaluates to true
(the delete
-based implementation above takes advantage of the fact that Z.concat
always returns a new string map).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's use this implementation:
var result = Z.concat(strMap, {});
delete result[key];
return result;
[The implementation above] is automatically updated when/if
Z.concat
is updated.
I consider this a requirement. I'm keen to avoid inconsistencies such as the one documented in ramda/ramda#1941.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We also have S.keys
. Whichever way we go with properties to include, I think we should have (pseudo code, I think I get to the idea):
function insert_test(key, val, strMap) {
new Set(S.keys(strMap).concat([key])) === new Set(S.keys(S.insert(key, val, strMap)))
}
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great point, Kevin! It would be good to test this property. Here are a few more:
S.insert (key) (val) (S.insert (key) (val) (map)) = S.insert (key) (val) (map)
S.remove (key) (S.remove (key) (map)) = S.remove (key) (map)
S.sort (S.keys (map)) = S.sort (S.keys (S.map (S.I) (map)))
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like the tests and have added them, except the last assertion you mentioned @davidchambers. I assume that test was meant for the keys
or the map
function? I can add that to this pull request if you want.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I assume that test was meant for the
keys
or themap
function?
Yes, you're right. It would assert that S.map
does not "promote" any properties which S.keys
does not see. Let's leave that for a separate pull request.
I look forward to seeing your changes. Feel free to push the changes you have made so far even if there are more to come. You can tidy the commit history before we merge the pull request. :)
test/remove.js
Outdated
var strMapAfterDeletion = S.remove(key, strMap); | ||
eq(strMap, strMapAfterDeletion); | ||
assert.strictEqual(strMap, strMapAfterDeletion); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Even if we decide to return the input in some cases, I don't think this should be enforced by the test suite. If, on the other hand, we decide it's important to return a new string map in all cases we could consider asserting that the output is not identical to the input.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we're specifying that remove
returns the original object, shouldn't we test for it? We could just not specify whether removing a non-existing key will return the original object. Or we could always return a new string map.
index.js
Outdated
//. {a: 1, b: 2} | ||
//. | ||
//. > S.remove('c', {a: 1, b: 2}) | ||
//. {a: 1, b: 2} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's drop this example. The following example is sufficient to demonstrate that "removing" a non-existent key is a valid operation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
True
Don't know if there was a notification, but I pushed changes yesterday |
There are notifications for regular pushes but not for I'll have a look at the changes now. :) |
index.js
Outdated
//# remove :: String -> StrMap a -> StrMap a | ||
//. | ||
//. Removes a given key and its value from a shallow clone of a given | ||
//. string map. The original string map is returned if the key doesn't exist. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The second sentence is no longer accurate.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You are correct, I apologize for the oversight! Pushing an update now.
index.js
Outdated
//# insert :: String -> a -> StrMap a -> StrMap a | ||
//. | ||
//. Inserts a given key and value into a shallow clone of a given | ||
//. string map. Overrides the value if a key already exists. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should replace a with the in several places. In the second sentence it's important that we write the key rather than a (any) key.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense.
test/insert.js
Outdated
eq( | ||
S.insert(key, val, S.insert(key, val, strMap)), | ||
S.insert(key, val, strMap) | ||
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This might make the double application even clearer:
var f = S.insert(key, val);
eq(f(f(strMap)), f(strMap));
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would this be clearer?
var insert = S.insert(key, val);
eq(insert(insert(strMap)), insert(strMap));
I can do the same for remove
:
var remove = S.remove(key);
eq(remove(remove(strMap)), remove(strMap));
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Works for me. 👍
The name doesn't much matter to me, so long as the pattern is clear. :)
test/insert.js
Outdated
); | ||
} | ||
|
||
function test(key, val, strMap, strMapResult) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should avoid shadowing the global test
function provided by Mocha.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes. I wasn't aware of the global test
function.
test/insert.js
Outdated
} | ||
}); | ||
return uniqueArray; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here's a less imperative implementation:
function unique(xs) {
if (xs.length === 0) return [];
var sorted = S.sort(xs);
var x = sorted[0];
return sorted.slice(1).reduce(function(acc, x) {
return S.equals(x, acc.prev) ? acc : {xs: S.append(x, acc.xs), prev: x};
}, {xs: [x], prev: x}).xs;
}
I think we should avoid requiring a sorted array as input, even if this means we end up sorting twice in some cases.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What about:
function unique(xs) {
return S.sort(xs).reduce(function(acc, x, i, xs) {
return i > 0 && S.equals(x, xs[i-1]) ? acc : S.append(x, acc);
}, []);
}
or
function appendIfUnique(acc, x, i, xs) {
return i > 0 && S.equals(x, xs[i-1])
? acc
: S.append(x, acc);
}
function unique(xs) {
return S.sort(xs).reduce(appendIfUnique, []);
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The use of indexes and the i > 0
guard in particular are the things which bother me. If we're to deal with indexes I'd prefer to use a for
loop:
function unique(xs) {
if (xs.length === 0) return [];
var sorted = S.sort(xs);
var prev = sorted[0];
var result = [prev];
for (var idx = 1; idx < sorted.length; idx += 1) {
if (!S.equals(sorted[idx], prev)) result.push(prev = sorted[idx]);
}
return result;
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can replace with your reduce
implementation. Just for my own edification, curious why you don't like indexes and i > 0
? Is it the potential for getting the wrong array element?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd prefer to handle the special case ([]
) in one place rather than allow it to complicate the common code path. I don't mind using array indexes when implementing Sanctuary functions, but in contexts in which performance is less of a concern (such as a test suite) I'd prefer to avoid "cheating" (keep in mind that S.map
and friends do not expose indexes). In this case we're using an index to access the previous element, but it seems more natural to me to keep track of this.
This is all subjective, and I don't mean to assert that my preferences are better than alternatives in any meaningful way.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated pull request.
test/remove.js
Outdated
testRemove('a', inherit({a: 1}, {b: 2}), {b: 2}); | ||
testRemove('c', inherit({a: 1}, {b: 2}), {a: 1, b: 2}); | ||
testRemove('a', inherit({b: 2, c: 3}, {a: 1}), {b: 2, c: 3}); | ||
testRemove('b', inherit({b: 2, c: 3}, {a: 1}), {a: 1, c: 3}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't see value in testing so many combinations. Would something like this suffice?
function Point() {}
Point.prototype.x = 0;
Point.prototype.y = 0;
eq(S.remove('x', new Point()), {y: 0});
eq(S.remove('y', new Point()), {x: 0});
eq(S.remove('z', new Point()), {x: 0, y: 0});
test/remove.js
Outdated
function testDoubleRemove(key, strMap) { | ||
var remove = S.remove(key); | ||
eq(remove(remove(strMap)), remove(strMap)); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's replace this with a property-based test:
jsc.assert(jsc.forall(jsc.string, jsc.dict(jsc.number), function(key, map) {
var remove = S.remove(key);
return S.equals(remove(remove(map)), remove(map));
}), {tests: 1000});
You should be able to do something similar for testDoubleInsert
. :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Was unaware of jsverify! Very nice. I've made the changes as well as the above Point
inherited properties test. Note that it does take well over triple the time to run the tests now on my MacBook due to insert.js taking 4.5secs and remove.js taking 3.5secs with these jsverify tests.
test/insert.js
Outdated
function testInsert(key, val, strMap, strMapResult) { | ||
testBasic(key, val, strMap, strMapResult); | ||
testKeysOfInsert(key, val, strMap); | ||
testDoubleInsert(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This means we test the property 1000 times each time we call testInsert
, which seems excessive. ;)
Testing the property 1000 times rather than 8000 times may prevent the tests from timing out on CircleCI.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Silly mistake on my part to call testDoubleInsert
on every call to testInsert
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No worries. :)
test/remove.js
Outdated
testRemove('a', {b: 2}, {b: 2}); | ||
testRemove('b', {a: 1, b: 2}, {a: 1}); | ||
testRemove('a', {b: 2, c: 3}, {b: 2, c: 3}); | ||
testRemove('b', {a: 1, b: 2, c: 3}, {a: 1, c: 3}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As mentioned above, let's test the property just 1000 times. This will mean we can remove testRemove
and make assertions here directly:
eq(S.remove('a', {}), {});
...
eq(S.remove('b', {a: 1, b: 2, c: 3}), {a: 1, c: 3});
I think we could achieve 100% coverage with fewer test cases. These should suffice:
eq(S.remove('x', {x: 1, y: 2}), {y: 2});
eq(S.remove('y', {x: 1, y: 2}), {x: 1});
eq(S.remove('z', {x: 1, y: 2}), {x: 1, y: 2});
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've made those changes locally. Are the insert
tests excessive as well?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would say so. I like to be able to look at each test case and see what it alone tests. I see only two distinct scenarios: either the key is already present or it is not. We really only need two unit tests (in addition to the property tests), but I don't mind including a few more if it makes a nice a
, b
, c
or 1
, 2
, 3
pattern.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated pull request with 3 insert
tests, making somewhat of an a
, b
/ 1
, 2
, 3
pattern.
test/remove.js
Outdated
|
||
eq(S.remove('x', new Point()), {y: 0}); | ||
eq(S.remove('y', new Point()), {x: 0}); | ||
eq(S.remove('z', new Point()), {x: 0, y: 0}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shall we include similar test cases for insert
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense
test/insert.js
Outdated
testInsert('a', 1, {}, {a: 1}); | ||
testInsert('b', 2, {a: 1}, {a: 1, b: 2}); | ||
testInsert('b', 3, {a: 1, b: 2}, {a: 1, b: 3}); | ||
testDoubleInsert(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's just include the body of testDoubleInsert
here. The code is self-descriptive.
test/insert.js
Outdated
unique(S.sort(S.concat(S.keys(strMap), [key]))), | ||
S.sort(S.keys(S.insert(key, val, strMap))) | ||
); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correct me if I'm wrong, but I don't think this tests anything not covered by other tests is this file. If we were to test new Point()
the equivalence would not hold because S.keys(new Point())
is []
rather than ['x', 'y']
. This suggests that S.keys
cannot be implemented in terms of Object.keys
. This changes the way I feel about sanctuary-js/sanctuary-type-classes#65. I'll comment on that issue shortly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added the S.keys
test above because of the conversation between you and @kedashoe earlier. I didn't realize S.keys(new Point())
would yield []
. I understand you're going to decide whether to make S.keys
get keys of enumerable inherited properties or change S.concat
to not get enumerable inherited properties. I can wait until you decide or remove testKeysOfInsert
entirely.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We'll update the behaviour of Z.concat
and S.concat
. The behaviour of S.keys
will remain as is.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So Z.concat
and S.concat
no longer include enumerable inherited properties? And I assume then this PR's S.insert
and S.remove
should also no longer include enumerable inherited properties. Then I'll no longer test for enumerable inherited properties or use new Point()
. Or I could explicitly test that enumerable inherited properties are not included in the results of S.insert
and S.remove
?
Do you want me to keep or delete the testKeysOfInsert
code?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@davidchambers just following up on this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense. I'll be ready for step 6 :). Am I correct in understanding that Z.concat
and S.concat
no longer include enumerable inherited properties?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Am I correct in understanding that
Z.concat
andS.concat
no longer include enumerable inherited properties?
Just about. Z.concat
and S.concat
will no longer include enumerable inherited properties. ;)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@samueller, I've completed steps 1–5 in the list above so you should no longer be blocked. :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
#439 introduced a StrMap section, so when you rebase please also update the commit message prefix from object:
to strmap:
.
I'd love to include these additions in the next release. I'm happy to make the final changes if you're busy with other things, @samueller. Let me know. |
Would you mind having another look at this pull request, @gabejohnson? 👀 |
//. {a: 4, b: 2} | ||
//. ``` | ||
function insert(key, val, strMap) { | ||
return Z.concat(strMap, singleton(key, val)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like this implementation in particular 😄
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do too. Thanks again, @masaeedu, for adding this handy function. :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a policy of not "cannibalizing" Sanctuary functions to implement other things? If you are allowed to use stuff from S
instead of just Z
, you could do something like const insert = compose2(concat, singleton)
which perhaps saves you the cost of having a wrapping function for just juggling the arguments around. I guess without a type system to guide you, getting too abstract gets confusing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think insert
`assoc` is used frequently enough to rate it's own function. Unless I'm misunderstanding you.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@gabejohnson I agree with having insert
as a separate function, I didn't mean to say this shouldn't be part of the library. I'm just saying that when we implement these functions, it seems like we prefer to use Z
in the implementation even when it makes the implementation a little more awkward than if we used the equivalent S
function.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is because of the overhead introduced by def
(currying, redundant type checking machinery). Some care is taken to write performant implementations.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Haha! Your use of italics just made me chuckle, Gabe. :)
We avoid using the curried functions, Asad, for the reason Gabe gave, but the implementation functions can freely be used in other implementations. The head
implementation, for example, is defined in terms of the at
implementation:
function head(xs) { return at(0, xs); }
Thanks for your work on this pull request, @samueller. :) |
As discussed in issue #434.